feat(runtime): add process watch patterns, unified backup/import, pluggable context engine, token usage, and codex device login

This commit is contained in:
lpf
2026-04-14 14:53:18 +08:00
parent fac235db80
commit 79e0a48b74
18 changed files with 1257 additions and 64 deletions

View File

@@ -33,11 +33,12 @@ const (
oauthStyleJSON = "json"
defaultCodexOAuthProvider = "codex"
defaultCodexAuthURL = "https://auth.openai.com/oauth/authorize"
defaultCodexAuthURL = "https://auth.openai.com/codex/device"
defaultCodexDeviceCodeURL = "https://auth.openai.com/api/accounts/deviceauth/usercode"
defaultCodexDeviceTokenPollURL = "https://auth.openai.com/api/accounts/deviceauth/token"
defaultCodexDeviceRedirectURL = "https://auth.openai.com/deviceauth/callback"
defaultCodexTokenURL = "https://auth.openai.com/oauth/token"
defaultCodexClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
defaultCodexCallbackPort = 1455
defaultCodexRedirectPath = "/auth/callback"
defaultClaudeOAuthProvider = "claude"
defaultClaudeAuthURL = "https://claude.ai/oauth/authorize"
defaultClaudeTokenURL = "https://api.anthropic.com/v1/oauth/token"
@@ -90,14 +91,14 @@ var (
)
var (
defaultAntigravityClientIDValue = "1071006060591-" + "tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
defaultAntigravityClientIDValue = "1071006060591-" + "tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
defaultAntigravityClientSecretValue = "GOCSPX-" + "K58FWR486LdLJ1mLB8sXC4z6qDAf"
defaultGeminiClientIDValue = "681255809395-" + "oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
defaultGeminiClientSecretValue = "GOCSPX-" + "4uHgMPm-1o7Sk-geV6Cu5clXFsxl"
defaultAntigravityClientID = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_ANTIGRAVITY_CLIENT_ID")), defaultAntigravityClientIDValue)
defaultAntigravityClientSecret = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_ANTIGRAVITY_CLIENT_SECRET")), defaultAntigravityClientSecretValue)
defaultGeminiClientID = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_GEMINI_CLIENT_ID")), defaultGeminiClientIDValue)
defaultGeminiClientSecret = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_GEMINI_CLIENT_SECRET")), defaultGeminiClientSecretValue)
defaultGeminiClientIDValue = "681255809395-" + "oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
defaultGeminiClientSecretValue = "GOCSPX-" + "4uHgMPm-1o7Sk-geV6Cu5clXFsxl"
defaultAntigravityClientID = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_ANTIGRAVITY_CLIENT_ID")), defaultAntigravityClientIDValue)
defaultAntigravityClientSecret = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_ANTIGRAVITY_CLIENT_SECRET")), defaultAntigravityClientSecretValue)
defaultGeminiClientID = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_GEMINI_CLIENT_ID")), defaultGeminiClientIDValue)
defaultGeminiClientSecret = firstNonEmpty(strings.TrimSpace(os.Getenv("CLAWGO_GEMINI_CLIENT_SECRET")), defaultGeminiClientSecretValue)
)
var (
@@ -152,6 +153,7 @@ type oauthConfig struct {
AuthURL string
TokenURL string
DeviceCodeURL string
DeviceTokenURL string
UserInfoURL string
RedirectURL string
RedirectPath string
@@ -611,11 +613,20 @@ func resolveOAuthConfig(pc config.ProviderConfig) (oauthConfig, error) {
}
switch provider {
case defaultCodexOAuthProvider:
cfg.CallbackPort = defaultInt(cfg.CallbackPort, defaultCodexCallbackPort)
cfg.FlowKind = oauthFlowDevice
cfg.ClientID = firstNonEmpty(cfg.ClientID, defaultCodexClientID)
cfg.AuthURL = firstNonEmpty(cfg.AuthURL, defaultCodexAuthURL)
cfg.AuthURL = firstNonEmpty(defaultCodexAuthURL)
cfg.TokenURL = firstNonEmpty(cfg.TokenURL, defaultCodexTokenURL)
cfg.RedirectPath = defaultCodexRedirectPath
deviceURL := strings.TrimSpace(pc.OAuth.AuthURL)
if deviceURL == "" || strings.Contains(strings.ToLower(deviceURL), "/oauth/authorize") {
deviceURL = defaultCodexDeviceCodeURL
}
cfg.DeviceCodeURL = deviceURL
if strings.Contains(strings.ToLower(deviceURL), "/usercode") {
cfg.DeviceTokenURL = strings.Replace(deviceURL, "/usercode", "/token", 1)
}
cfg.DeviceTokenURL = firstNonEmpty(cfg.DeviceTokenURL, defaultCodexDeviceTokenPollURL)
cfg.RedirectURL = firstNonEmpty(strings.TrimSpace(pc.OAuth.RedirectURL), defaultCodexDeviceRedirectURL)
if len(cfg.Scopes) == 0 {
cfg.Scopes = append([]string(nil), defaultCodexScopes...)
}
@@ -743,11 +754,15 @@ func (m *oauthManager) login(ctx context.Context, apiBase string, opts OAuthLogi
if err != nil {
return nil, nil, err
}
fmt.Printf("Open this URL to continue OAuth login:\n%s\n", flow.AuthURL)
if m.cfg.Provider == defaultCodexOAuthProvider {
fmt.Printf("To continue Codex login:\n1) Open: %s\n2) Enter code: %s\n", flow.AuthURL, strings.TrimSpace(flow.UserCode))
} else {
fmt.Printf("Open this URL to continue OAuth login:\n%s\n", flow.AuthURL)
}
if strings.TrimSpace(flow.UserCode) != "" {
fmt.Printf("User code: %s\n", flow.UserCode)
}
if !opts.NoBrowser {
if !opts.NoBrowser && m.cfg.Provider != defaultCodexOAuthProvider {
if err := openBrowser(flow.AuthURL); err != nil {
fmt.Printf("Automatic browser open failed: %v\n", err)
}
@@ -2379,6 +2394,32 @@ func (m *oauthManager) startDeviceFlow(ctx context.Context, opts OAuthLoginOptio
form := url.Values{}
form.Set("client_id", m.cfg.ClientID)
switch m.cfg.Provider {
case defaultCodexOAuthProvider:
body, _ := json.Marshal(map[string]any{"client_id": m.cfg.ClientID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.cfg.DeviceCodeURL, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
raw, err := m.doJSONRequest(req, "oauth device request", opts.NetworkProxy)
if err != nil {
return nil, err
}
userCode := strings.TrimSpace(asString(raw["user_code"]))
deviceAuthID := strings.TrimSpace(asString(raw["device_auth_id"]))
if userCode == "" || deviceAuthID == "" {
return nil, fmt.Errorf("oauth device flow missing user_code/device_auth_id")
}
return &OAuthPendingFlow{
Mode: oauthFlowDevice,
AuthURL: firstNonEmpty(strings.TrimSpace(m.cfg.AuthURL), defaultCodexAuthURL),
UserCode: userCode,
DeviceCode: deviceAuthID,
IntervalSec: defaultInt(asInt(raw["interval"]), 5),
ExpiresAt: deviceExpiry(asInt(raw["expires_in"])),
Instructions: "Open the verification URL, enter the user code, and approve the device login.",
}, nil
case defaultQwenOAuthProvider:
verifier, challenge, err := generatePKCE()
if err != nil {
@@ -2430,6 +2471,26 @@ func (m *oauthManager) startDeviceFlow(ctx context.Context, opts OAuthLoginOptio
}
}
func asInt(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case json.Number:
if parsed, err := n.Int64(); err == nil {
return int(parsed)
}
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return parsed
}
}
return 0
}
func (m *oauthManager) doFormDeviceRequest(ctx context.Context, endpoint string, form url.Values, proxyURL string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
@@ -2513,6 +2574,9 @@ func (m *oauthManager) pollDeviceToken(ctx context.Context, flow *OAuthPendingFl
if flow == nil || strings.TrimSpace(flow.DeviceCode) == "" {
return nil, fmt.Errorf("oauth device flow missing device code")
}
if m.cfg.Provider == defaultCodexOAuthProvider {
return m.pollCodexDeviceToken(ctx, flow, proxyURL)
}
interval := time.Duration(defaultInt(flow.IntervalSec, 5)) * time.Second
deadline := time.Now().Add(m.cfg.DevicePollMax)
if expireAt, err := time.Parse(time.RFC3339, strings.TrimSpace(flow.ExpiresAt)); err == nil && expireAt.Before(deadline) {
@@ -2560,3 +2624,78 @@ func (m *oauthManager) pollDeviceToken(ctx context.Context, flow *OAuthPendingFl
}
}
}
func (m *oauthManager) pollCodexDeviceToken(ctx context.Context, flow *OAuthPendingFlow, proxyURL string) (*oauthSession, error) {
interval := time.Duration(defaultInt(flow.IntervalSec, 5)) * time.Second
if interval < 3*time.Second {
interval = 3 * time.Second
}
deadline := time.Now().Add(m.cfg.DevicePollMax)
if expireAt, err := time.Parse(time.RFC3339, strings.TrimSpace(flow.ExpiresAt)); err == nil && expireAt.Before(deadline) {
deadline = expireAt
}
for {
if time.Now().After(deadline) {
return nil, fmt.Errorf("oauth device flow timed out")
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
payload, _ := json.Marshal(map[string]any{
"device_auth_id": strings.TrimSpace(flow.DeviceCode),
"user_code": strings.TrimSpace(flow.UserCode),
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.cfg.DeviceTokenURL, strings.NewReader(string(payload)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client, err := m.httpClientForProxy(proxyURL)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("oauth device poll request failed: %w", err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("oauth device poll read failed: %w", readErr)
}
switch resp.StatusCode {
case http.StatusOK:
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("oauth device poll decode failed: %w", err)
}
authCode := strings.TrimSpace(asString(raw["authorization_code"]))
verifier := strings.TrimSpace(asString(raw["code_verifier"]))
if authCode == "" || verifier == "" {
return nil, fmt.Errorf("oauth device auth response missing authorization_code/code_verifier")
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", authCode)
form.Set("redirect_uri", m.cfg.RedirectURL)
form.Set("client_id", m.cfg.ClientID)
form.Set("code_verifier", verifier)
tokenRaw, err := m.doFormTokenRequest(ctx, form, proxyURL)
if err != nil {
return nil, err
}
session, err := sessionFromTokenPayload(m.cfg.Provider, tokenRaw)
if err != nil {
return nil, err
}
return m.enrichSession(ctx, session)
case http.StatusForbidden, http.StatusNotFound:
continue
default:
return nil, fmt.Errorf("oauth device poll failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body)))
}
}
}

View File

@@ -323,6 +323,7 @@ func TestResolveOAuthConfigSupportsAdditionalProviders(t *testing.T) {
want string
flow string
}{
{name: "codex", provider: "codex", want: "codex", flow: oauthFlowDevice},
{name: "anthropic-alias", provider: "anthropic", want: "claude", flow: oauthFlowCallback},
{name: "antigravity", provider: "antigravity", want: "antigravity", flow: oauthFlowCallback},
{name: "gemini", provider: "gemini", want: "gemini", flow: oauthFlowCallback},
@@ -951,6 +952,97 @@ func TestOAuthDeviceFlowQwenManualCompletes(t *testing.T) {
}
}
func TestOAuthDeviceFlowCodexStartAndComplete(t *testing.T) {
t.Parallel()
var pollAttempts int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/accounts/deviceauth/usercode":
if got := r.Header.Get("Content-Type"); !strings.Contains(strings.ToLower(got), "application/json") {
t.Fatalf("expected json content type, got %s", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"user_code":"U-CODE","device_auth_id":"dev-auth-1","interval":0,"expires_in":60}`))
case "/api/accounts/deviceauth/token":
attempt := atomic.AddInt32(&pollAttempts, 1)
if attempt == 1 {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{}`))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"authorization_code":"auth-code-1","code_verifier":"verifier-1"}`))
case "/oauth/token":
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form failed: %v", err)
}
if got := r.Form.Get("grant_type"); got != "authorization_code" {
t.Fatalf("unexpected grant_type: %s", got)
}
if got := r.Form.Get("code"); got != "auth-code-1" {
t.Fatalf("unexpected code: %s", got)
}
if got := r.Form.Get("code_verifier"); got != "verifier-1" {
t.Fatalf("unexpected code_verifier: %s", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"codex-at","refresh_token":"codex-rt","expires_in":3600}`))
case "/models":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.4"}]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
dir := t.TempDir()
manager, err := newOAuthManager(config.ProviderConfig{
APIBase: server.URL,
Auth: "oauth",
OAuth: config.ProviderOAuthConfig{
Provider: "codex",
AuthURL: server.URL + "/api/accounts/deviceauth/usercode",
TokenURL: server.URL + "/oauth/token",
RedirectURL: server.URL + "/deviceauth/callback",
CredentialFile: filepath.Join(dir, "codex.json"),
},
}, 5*time.Second)
if err != nil {
t.Fatalf("new oauth manager failed: %v", err)
}
defer manager.bgCancel()
flow, err := manager.startDeviceFlow(context.Background(), OAuthLoginOptions{})
if err != nil {
t.Fatalf("start device flow failed: %v", err)
}
if flow.Mode != oauthFlowDevice {
t.Fatalf("unexpected flow mode: %s", flow.Mode)
}
if flow.UserCode != "U-CODE" || flow.DeviceCode != "dev-auth-1" {
t.Fatalf("unexpected flow payload: %#v", flow)
}
if flow.IntervalSec < 1 {
t.Fatalf("expected normalized poll interval >=1, got %d", flow.IntervalSec)
}
session, models, err := manager.completeDeviceFlow(context.Background(), server.URL, flow, OAuthLoginOptions{})
if err != nil {
t.Fatalf("complete device flow failed: %v", err)
}
if session.AccessToken != "codex-at" || session.RefreshToken != "codex-rt" {
t.Fatalf("unexpected session tokens: %#v", session)
}
if atomic.LoadInt32(&pollAttempts) < 2 {
t.Fatalf("expected polling retries, got %d", pollAttempts)
}
if len(models) != 1 || models[0] != "gpt-5.4" {
t.Fatalf("unexpected models: %#v", models)
}
}
func TestHTTPProviderHybridFallsBackFromAPIKeyToOAuth(t *testing.T) {
t.Parallel()