From 39c80d3e1f78a28543ac1fad0a122998d5e35932 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 21 Apr 2026 23:38:12 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20GitHub=20and=20?= =?UTF-8?q?Lark=20auth=20plugins=20for=20OAuth=20app=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: claude-code:claude-sonnet-4-6 --- cmd/anna/plugins_imports.go | 4 + plugins/auth/github/plugin.go | 115 ++++++++++++++++++++++++++++ plugins/auth/lark/plugin.go | 137 ++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 plugins/auth/github/plugin.go create mode 100644 plugins/auth/lark/plugin.go diff --git a/cmd/anna/plugins_imports.go b/cmd/anna/plugins_imports.go index bf178af9..ad66c6d0 100644 --- a/cmd/anna/plugins_imports.go +++ b/cmd/anna/plugins_imports.go @@ -1,6 +1,10 @@ package main import ( + // Plugin auth. + _ "github.com/vaayne/anna/plugins/auth/github" + _ "github.com/vaayne/anna/plugins/auth/lark" + // Plugin channels. _ "github.com/vaayne/anna/plugins/channels/feishu" _ "github.com/vaayne/anna/plugins/channels/qq" diff --git a/plugins/auth/github/plugin.go b/plugins/auth/github/plugin.go new file mode 100644 index 00000000..331cfbb4 --- /dev/null +++ b/plugins/auth/github/plugin.go @@ -0,0 +1,115 @@ +package github + +import ( + "fmt" + "maps" + + pkgplugins "github.com/vaayne/anna/pkg/plugins" +) + +const PluginID = "auth/github" + +type Config struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +func defaultConfig() map[string]any { + return map[string]any{ + "client_id": "", + "client_secret": "", + } +} + +func configSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "client_id": map[string]any{ + "type": "string", + "description": "GitHub OAuth app client ID.", + }, + "client_secret": map[string]any{ + "type": "string", + "description": "GitHub OAuth app client secret.", + }, + }, + "required": []any{"client_id", "client_secret"}, + } +} + +func decodeConfig(raw map[string]any) (Config, error) { + var cfg Config + if v, ok := raw["client_id"]; ok { + s, ok := v.(string) + if !ok { + return Config{}, fmt.Errorf("client_id: must be a string") + } + cfg.ClientID = s + } + if v, ok := raw["client_secret"]; ok { + s, ok := v.(string) + if !ok { + return Config{}, fmt.Errorf("client_secret: must be a string") + } + cfg.ClientSecret = s + } + return cfg, nil +} + +func validateConfig(raw map[string]any) error { + cfg, err := decodeConfig(raw) + if err != nil { + return err + } + if cfg.ClientID == "" { + return fmt.Errorf("client_id: required") + } + if cfg.ClientSecret == "" { + return fmt.Errorf("client_secret: required") + } + return nil +} + +func redactConfig(raw map[string]any) map[string]any { + out := make(map[string]any, len(raw)) + maps.Copy(out, raw) + if _, ok := out["client_secret"]; ok { + out["client_secret"] = "***" + } + return out +} + +func isConfigured(raw map[string]any) bool { + cfg, err := decodeConfig(raw) + return err == nil && cfg.ClientID != "" && cfg.ClientSecret != "" +} + +func init() { + pkgplugins.Register(PluginID, pkgplugins.PluginFunc(func(host pkgplugins.Host) { + host.SetInfo(pkgplugins.PluginInfo{ + ID: PluginID, + Kind: "auth", + Name: "github", + DisplayName: "GitHub", + Description: "GitHub OAuth app credentials for device-flow authentication.", + AdminVisible: true, + HasConfig: true, + Capabilities: []string{ + pkgplugins.CapabilityConfig, + }, + }) + host.AddAdmin(pkgplugins.AdminSpec{ + PluginID: PluginID, + DefaultConfig: defaultConfig, + Schema: configSchema(), + Validate: validateConfig, + Redact: redactConfig, + }) + })) +} + +// Configured reports whether the plugin has all required credentials set. +func Configured(raw map[string]any) bool { + return isConfigured(raw) +} diff --git a/plugins/auth/lark/plugin.go b/plugins/auth/lark/plugin.go new file mode 100644 index 00000000..38c14830 --- /dev/null +++ b/plugins/auth/lark/plugin.go @@ -0,0 +1,137 @@ +package lark + +import ( + "fmt" + "maps" + + pkgplugins "github.com/vaayne/anna/pkg/plugins" +) + +const PluginID = "auth/lark" + +type Config struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + Brand string `json:"brand"` +} + +func defaultConfig() map[string]any { + return map[string]any{ + "app_id": "", + "app_secret": "", + "brand": "lark", + } +} + +func configSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "app_id": map[string]any{ + "type": "string", + "description": "Lark/Feishu OAuth app ID.", + }, + "app_secret": map[string]any{ + "type": "string", + "description": "Lark/Feishu OAuth app secret.", + }, + "brand": map[string]any{ + "type": "string", + "enum": []any{"lark", "feishu"}, + "description": "Platform brand: \"lark\" (international) or \"feishu\" (China).", + "default": "lark", + }, + }, + "required": []any{"app_id", "app_secret", "brand"}, + } +} + +func decodeConfig(raw map[string]any) (Config, error) { + var cfg Config + if v, ok := raw["app_id"]; ok { + s, ok := v.(string) + if !ok { + return Config{}, fmt.Errorf("app_id: must be a string") + } + cfg.AppID = s + } + if v, ok := raw["app_secret"]; ok { + s, ok := v.(string) + if !ok { + return Config{}, fmt.Errorf("app_secret: must be a string") + } + cfg.AppSecret = s + } + if v, ok := raw["brand"]; ok { + s, ok := v.(string) + if !ok { + return Config{}, fmt.Errorf("brand: must be a string") + } + cfg.Brand = s + } + return cfg, nil +} + +func validateConfig(raw map[string]any) error { + cfg, err := decodeConfig(raw) + if err != nil { + return err + } + if cfg.AppID == "" { + return fmt.Errorf("app_id: required") + } + if cfg.AppSecret == "" { + return fmt.Errorf("app_secret: required") + } + switch cfg.Brand { + case "lark", "feishu": + case "": + return fmt.Errorf("brand: required") + default: + return fmt.Errorf("brand: must be one of \"lark\" or \"feishu\"") + } + return nil +} + +func redactConfig(raw map[string]any) map[string]any { + out := make(map[string]any, len(raw)) + maps.Copy(out, raw) + if _, ok := out["app_secret"]; ok { + out["app_secret"] = "***" + } + return out +} + +func isConfigured(raw map[string]any) bool { + cfg, err := decodeConfig(raw) + return err == nil && cfg.AppID != "" && cfg.AppSecret != "" && cfg.Brand != "" +} + +func init() { + pkgplugins.Register(PluginID, pkgplugins.PluginFunc(func(host pkgplugins.Host) { + host.SetInfo(pkgplugins.PluginInfo{ + ID: PluginID, + Kind: "auth", + Name: "lark", + DisplayName: "Lark / Feishu", + Description: "Lark/Feishu OAuth app credentials for device-flow authentication.", + AdminVisible: true, + HasConfig: true, + Capabilities: []string{ + pkgplugins.CapabilityConfig, + }, + }) + host.AddAdmin(pkgplugins.AdminSpec{ + PluginID: PluginID, + DefaultConfig: defaultConfig, + Schema: configSchema(), + Validate: validateConfig, + Redact: redactConfig, + }) + })) +} + +// Configured reports whether the plugin has all required credentials set. +func Configured(raw map[string]any) bool { + return isConfigured(raw) +} From 1b49ecdd789f989de0b64eaf5376e74501e3e9c2 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 21 Apr 2026 23:44:25 +0800 Subject: [PATCH 02/21] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20oauthcli=20pack?= =?UTF-8?q?age=20with=20device-flow=20broker=20and=20token=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements internal/oauthcli/ — host-side OAuth plumbing for GitHub and Lark/Feishu. Includes a mutex-protected in-memory FlowStore, JSON vault serialisation helpers, GitHubBroker (full RFC 8628 device flow with slow_down/expired handling), LarkBroker (authorisation-code flow adapted for device-like use with app-access-token exchange), and TokenManager with automatic Lark access-token refresh. LarkOAuthBundle stores AppSecret at rest (already encrypted by the vault) so TokenManager is self-contained for refresh without requiring the credentials at call time. Assisted-by: claude-code:claude-sonnet-4-6 --- internal/oauthcli/gh.go | 296 +++++++++++++++++++++++++++++ internal/oauthcli/lark.go | 257 +++++++++++++++++++++++++ internal/oauthcli/store.go | 54 ++++++ internal/oauthcli/token_manager.go | 149 +++++++++++++++ internal/oauthcli/types.go | 64 +++++++ internal/oauthcli/vault.go | 82 ++++++++ 6 files changed, 902 insertions(+) create mode 100644 internal/oauthcli/gh.go create mode 100644 internal/oauthcli/lark.go create mode 100644 internal/oauthcli/store.go create mode 100644 internal/oauthcli/token_manager.go create mode 100644 internal/oauthcli/types.go create mode 100644 internal/oauthcli/vault.go diff --git a/internal/oauthcli/gh.go b/internal/oauthcli/gh.go new file mode 100644 index 00000000..1de2f84e --- /dev/null +++ b/internal/oauthcli/gh.go @@ -0,0 +1,296 @@ +package oauthcli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + ghDeviceCodeURL = "https://github.com/login/device/code" + ghAccessTokenURL = "https://github.com/login/oauth/access_token" + ghScope = "repo,read:org" +) + +// GitHubConfig holds the OAuth app credentials for device flow. +type GitHubConfig struct { + ClientID string + ClientSecret string +} + +// ghFlowSecret holds provider-specific secrets for an in-flight GitHub flow. +type ghFlowSecret struct { + deviceCode string + interval int // seconds between polls, as returned by GitHub + bundle *GHOAuthBundle +} + +// GitHubBroker manages GitHub device-flow sessions. +type GitHubBroker struct { + cfg GitHubConfig + store *FlowStore + mu sync.Mutex + secret map[string]*ghFlowSecret // flowID → secrets +} + +// NewGitHubBroker constructs a GitHubBroker. +func NewGitHubBroker(cfg GitHubConfig, store *FlowStore) *GitHubBroker { + return &GitHubBroker{ + cfg: cfg, + store: store, + secret: make(map[string]*ghFlowSecret), + } +} + +// ghDeviceCodeResponse is the JSON body from POST /login/device/code. +type ghDeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// ghAccessTokenResponse is the JSON body from POST /login/oauth/access_token. +type ghAccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error"` + // Fine-grained tokens may carry expiry. + ExpiresIn int `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// StartDeviceFlow requests a device code from GitHub, stores pending state, +// and returns the FlowStatus the caller should display to the user. +func (b *GitHubBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowStatus, error) { + body := url.Values{} + body.Set("client_id", b.cfg.ClientID) + body.Set("scope", ghScope) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghDeviceCodeURL, + strings.NewReader(body.Encode())) + if err != nil { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: build device code request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: device code request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: device code: unexpected status %d", resp.StatusCode) + } + + var dc ghDeviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: decode device code response: %w", err) + } + + if dc.Interval <= 0 { + dc.Interval = 5 // GitHub default + } + + flowID := uuid.NewString() + expiresAt := time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second) + + status := FlowStatus{ + Provider: ProviderGitHub, + FlowID: flowID, + VerificationURI: dc.VerificationURI, + UserCode: dc.UserCode, + ExpiresAt: expiresAt, + State: FlowStatePending, + } + + b.store.Create(status) + + b.mu.Lock() + b.secret[flowID] = &ghFlowSecret{ + deviceCode: dc.DeviceCode, + interval: dc.Interval, + } + b.mu.Unlock() + + return status, nil +} + +// Poll checks whether the user has completed authorization for flowID. +// It updates the store state and returns the current FlowStatus. +// Callers must call Complete after Poll returns State == FlowStateAuthorized. +func (b *GitHubBroker) Poll(ctx context.Context, flowID string) (FlowStatus, error) { + status, ok := b.store.Get(flowID) + if !ok { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: unknown flow %q", flowID) + } + + if status.State != FlowStatePending { + return status, nil + } + + if time.Now().After(status.ExpiresAt) { + b.store.Update(flowID, FlowStateExpired, nil) + b.cleanSecret(flowID) + status.State = FlowStateExpired + return status, nil + } + + b.mu.Lock() + sec, ok := b.secret[flowID] + b.mu.Unlock() + if !ok { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: missing secrets for flow %q", flowID) + } + + reqBody := url.Values{} + reqBody.Set("client_id", b.cfg.ClientID) + reqBody.Set("device_code", sec.deviceCode) + reqBody.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghAccessTokenURL, + strings.NewReader(reqBody.Encode())) + if err != nil { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: build poll request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: poll request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var at ghAccessTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&at); err != nil { + return FlowStatus{}, fmt.Errorf("oauthcli/gh: decode poll response: %w", err) + } + + switch at.Error { + case "": + // Success — stash the token bundle, mark authorized. + bundle := &GHOAuthBundle{ + Version: 1, + AccessToken: at.AccessToken, + TokenType: at.TokenType, + Scope: at.Scope, + RefreshToken: at.RefreshToken, + } + if at.ExpiresIn > 0 { + t := time.Now().Add(time.Duration(at.ExpiresIn) * time.Second) + bundle.ExpiresAt = &t + } + b.mu.Lock() + sec.bundle = bundle + b.mu.Unlock() + b.store.Update(flowID, FlowStateAuthorized, nil) + status.State = FlowStateAuthorized + return status, nil + + case "authorization_pending": + // Still waiting — no change. + return status, nil + + case "slow_down": + // GitHub wants us to back off; bump the stored interval and keep waiting. + b.mu.Lock() + sec.interval += 5 + b.mu.Unlock() + return status, nil + + case "expired_token": + b.store.Update(flowID, FlowStateExpired, nil) + b.cleanSecret(flowID) + status.State = FlowStateExpired + return status, nil + + case "access_denied": + b.store.Update(flowID, FlowStateFailed, nil) + b.cleanSecret(flowID) + status.State = FlowStateFailed + return status, fmt.Errorf("oauthcli/gh: access denied by user") + + default: + b.store.Update(flowID, FlowStateFailed, nil) + b.cleanSecret(flowID) + status.State = FlowStateFailed + return status, fmt.Errorf("oauthcli/gh: unexpected error %q from GitHub", at.Error) + } +} + +// Complete persists the token bundle to vault. Must be called only after Poll +// returns State == FlowStateAuthorized. +func (b *GitHubBroker) Complete(ctx context.Context, vs VaultStore, userID int64, flowID string) error { + b.mu.Lock() + sec, ok := b.secret[flowID] + b.mu.Unlock() + if !ok { + return fmt.Errorf("oauthcli/gh: no authorized token for flow %q", flowID) + } + if sec.bundle == nil { + return fmt.Errorf("oauthcli/gh: flow %q is not yet authorized", flowID) + } + + if err := SaveGHBundle(ctx, vs, userID, *sec.bundle); err != nil { + return err + } + + b.store.Delete(flowID) + b.cleanSecret(flowID) + return nil +} + +// PollInterval returns the current recommended seconds-between-polls for a +// flow. Returns 5 (GitHub default) if the flow is unknown. +func (b *GitHubBroker) PollInterval(flowID string) int { + b.mu.Lock() + defer b.mu.Unlock() + if sec, ok := b.secret[flowID]; ok { + return sec.interval + } + return 5 +} + +func (b *GitHubBroker) cleanSecret(flowID string) { + b.mu.Lock() + delete(b.secret, flowID) + b.mu.Unlock() +} + +// postJSON is a small helper to POST a JSON body and decode the response. +func postJSON(ctx context.Context, url string, reqBody any, headers map[string]string, out any) error { + data, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go new file mode 100644 index 00000000..d2c25d33 --- /dev/null +++ b/internal/oauthcli/lark.go @@ -0,0 +1,257 @@ +package oauthcli + +import ( + "context" + "fmt" + "net/url" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + larkBaseFeishu = "https://open.feishu.cn" + larkBaseLark = "https://open.larksuite.com" + + larkRedirectURI = "https://anna.app/oauth/lark/callback" // placeholder; caller sets up the real redirect +) + +// LarkConfig holds the OAuth app credentials for Lark/Feishu device-style flow. +type LarkConfig struct { + AppID string + AppSecret string + Brand string // "lark" or "feishu" +} + +// larkFlowSecret holds provider-specific state for an in-flight Lark flow. +type larkFlowSecret struct { + bundle *LarkOAuthBundle +} + +// LarkBroker manages Lark/Feishu OAuth sessions via the authorization-code +// flow adapted for device-like use: StartDeviceFlow returns a URL the user +// visits; Poll checks completion; Complete exchanges the code and saves the +// bundle to vault. +type LarkBroker struct { + cfg LarkConfig + store *FlowStore + redirectURI string + mu sync.Mutex + secret map[string]*larkFlowSecret +} + +// NewLarkBroker constructs a LarkBroker. redirectURI is the OAuth callback URL +// that your HTTP handler will receive and then call Complete on. +func NewLarkBroker(cfg LarkConfig, store *FlowStore) *LarkBroker { + return &LarkBroker{ + cfg: cfg, + store: store, + redirectURI: larkRedirectURI, + secret: make(map[string]*larkFlowSecret), + } +} + +// WithRedirectURI returns a new broker with the same configuration but the +// given redirect URI. The new broker has its own independent mutex and secret +// map, so it is safe to use concurrently with the original. +func (b *LarkBroker) WithRedirectURI(uri string) *LarkBroker { + return &LarkBroker{ + cfg: b.cfg, + store: b.store, + redirectURI: uri, + secret: make(map[string]*larkFlowSecret), + } +} + +func (b *LarkBroker) baseURL() string { + if b.cfg.Brand == "feishu" { + return larkBaseFeishu + } + return larkBaseLark +} + +// StartDeviceFlow generates a state token, constructs the authorization URL, +// and stores a pending FlowStatus. The user must navigate to VerificationURI. +func (b *LarkBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowStatus, error) { + flowID := uuid.NewString() + expiresAt := time.Now().Add(10 * time.Minute) // Lark code flows typically timeout within minutes + + authURL, err := b.buildAuthURL(flowID) + if err != nil { + return FlowStatus{}, fmt.Errorf("oauthcli/lark: build auth url: %w", err) + } + + status := FlowStatus{ + Provider: ProviderLark, + FlowID: flowID, + VerificationURI: authURL, + ExpiresAt: expiresAt, + State: FlowStatePending, + } + + b.store.Create(status) + b.mu.Lock() + b.secret[flowID] = &larkFlowSecret{} + b.mu.Unlock() + + return status, nil +} + +// Poll checks whether the flow identified by flowID has been completed. +// Completion is signaled externally via Complete after the OAuth callback is received. +func (b *LarkBroker) Poll(ctx context.Context, flowID string) (FlowStatus, error) { + status, ok := b.store.Get(flowID) + if !ok { + return FlowStatus{}, fmt.Errorf("oauthcli/lark: unknown flow %q", flowID) + } + + if status.State != FlowStatePending { + return status, nil + } + + if time.Now().After(status.ExpiresAt) { + b.store.Update(flowID, FlowStateExpired, nil) + b.cleanSecret(flowID) + status.State = FlowStateExpired + return status, nil + } + + // Check if Complete was called and stashed a bundle. + b.mu.Lock() + sec, ok := b.secret[flowID] + b.mu.Unlock() + if ok && sec.bundle != nil { + status.State = FlowStateAuthorized + return status, nil + } + + return status, nil +} + +// Complete exchanges an authorization code for tokens, saves the bundle to +// vault, and marks the flow as authorized. The code comes from your OAuth +// callback handler's query parameter. +func (b *LarkBroker) Complete(ctx context.Context, vs VaultStore, userID int64, flowID string, code string) error { + _, ok := b.store.Get(flowID) + if !ok { + return fmt.Errorf("oauthcli/lark: unknown flow %q", flowID) + } + + appToken, err := b.fetchAppAccessToken(ctx) + if err != nil { + return fmt.Errorf("oauthcli/lark: fetch app access token: %w", err) + } + + bundle, err := b.exchangeCode(ctx, appToken, code) + if err != nil { + return fmt.Errorf("oauthcli/lark: exchange code: %w", err) + } + bundle.AppID = b.cfg.AppID + bundle.AppSecret = b.cfg.AppSecret + bundle.Brand = b.cfg.Brand + bundle.Version = 1 + + if err := SaveLarkBundle(ctx, vs, userID, *bundle); err != nil { + return err + } + + // Update store so polling callers see the completion immediately. + b.store.Update(flowID, FlowStateAuthorized, nil) + b.mu.Lock() + if sec, ok := b.secret[flowID]; ok { + sec.bundle = bundle + } + b.mu.Unlock() + + b.store.Delete(flowID) + b.cleanSecret(flowID) + return nil +} + +// larkAppTokenResponse is the JSON body from the app_access_token endpoint. +type larkAppTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + AppAccessToken string `json:"app_access_token"` + Expire int `json:"expire"` +} + +// fetchAppAccessToken obtains a short-lived Lark app access token. +func (b *LarkBroker) fetchAppAccessToken(ctx context.Context) (string, error) { + endpoint := b.baseURL() + "/open-apis/auth/v3/app_access_token/internal" + reqBody := map[string]string{ + "app_id": b.cfg.AppID, + "app_secret": b.cfg.AppSecret, + } + var resp larkAppTokenResponse + if err := postJSON(ctx, endpoint, reqBody, nil, &resp); err != nil { + return "", fmt.Errorf("app_access_token request: %w", err) + } + if resp.Code != 0 { + return "", fmt.Errorf("app_access_token error %d: %s", resp.Code, resp.Msg) + } + return resp.AppAccessToken, nil +} + +// larkUserTokenResponse is the JSON body from the OIDC access_token endpoint. +type larkUserTokenResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + TokenType string `json:"token_type"` + } `json:"data"` +} + +// exchangeCode exchanges an authorization code for a user access token. +func (b *LarkBroker) exchangeCode(ctx context.Context, appToken string, code string) (*LarkOAuthBundle, error) { + endpoint := b.baseURL() + "/open-apis/authen/v1/oidc/access_token" + reqBody := map[string]string{ + "grant_type": "authorization_code", + "code": code, + } + headers := map[string]string{ + "Authorization": "Bearer " + appToken, + } + var resp larkUserTokenResponse + if err := postJSON(ctx, endpoint, reqBody, headers, &resp); err != nil { + return nil, fmt.Errorf("token exchange request: %w", err) + } + if resp.Code != 0 { + return nil, fmt.Errorf("token exchange error %d: %s", resp.Code, resp.Msg) + } + + now := time.Now() + bundle := &LarkOAuthBundle{ + AccessToken: resp.Data.AccessToken, + RefreshToken: resp.Data.RefreshToken, + AccessExpiresAt: now.Add(time.Duration(resp.Data.ExpiresIn) * time.Second), + RefreshExpiresAt: now.Add(time.Duration(resp.Data.RefreshExpiresIn) * time.Second), + } + return bundle, nil +} + +func (b *LarkBroker) buildAuthURL(state string) (string, error) { + base := b.baseURL() + "/open-apis/authen/v1/authorize" + params := url.Values{} + params.Set("app_id", b.cfg.AppID) + params.Set("redirect_uri", b.redirectURI) + params.Set("state", state) + params.Set("scope", "contact:user.base:readonly") + u, err := url.Parse(base) + if err != nil { + return "", err + } + u.RawQuery = params.Encode() + return u.String(), nil +} + +func (b *LarkBroker) cleanSecret(flowID string) { + b.mu.Lock() + delete(b.secret, flowID) + b.mu.Unlock() +} diff --git a/internal/oauthcli/store.go b/internal/oauthcli/store.go new file mode 100644 index 00000000..640e1e06 --- /dev/null +++ b/internal/oauthcli/store.go @@ -0,0 +1,54 @@ +package oauthcli + +import "sync" + +// FlowStore is an in-memory store of in-flight device-flow sessions. +// Known limitation: a process restart loses all pending flows. +type FlowStore struct { + mu sync.Mutex + flows map[string]FlowStatus +} + +// NewFlowStore returns an empty FlowStore. +func NewFlowStore() *FlowStore { + return &FlowStore{flows: make(map[string]FlowStatus)} +} + +// Create stores a new FlowStatus keyed by its FlowID. +func (s *FlowStore) Create(status FlowStatus) { + s.mu.Lock() + defer s.mu.Unlock() + s.flows[status.FlowID] = status +} + +// Get returns the FlowStatus for flowID, or false if not found. +func (s *FlowStore) Get(flowID string) (FlowStatus, bool) { + s.mu.Lock() + defer s.mu.Unlock() + fs, ok := s.flows[flowID] + return fs, ok +} + +// Update sets state on the named flow and then calls update (if non-nil) to +// allow further mutation. The state is applied first so callers can inspect or +// override it inside update. +func (s *FlowStore) Update(flowID string, state FlowState, update func(*FlowStatus)) { + s.mu.Lock() + defer s.mu.Unlock() + fs, ok := s.flows[flowID] + if !ok { + return + } + fs.State = state + if update != nil { + update(&fs) + } + s.flows[flowID] = fs +} + +// Delete removes the flow with the given ID from the store. +func (s *FlowStore) Delete(flowID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.flows, flowID) +} diff --git a/internal/oauthcli/token_manager.go b/internal/oauthcli/token_manager.go new file mode 100644 index 00000000..bf9a3c54 --- /dev/null +++ b/internal/oauthcli/token_manager.go @@ -0,0 +1,149 @@ +package oauthcli + +import ( + "context" + "fmt" + "time" +) + +// tokenExpirySafetyMargin is subtracted from expiry times to avoid using +// tokens that are about to expire during a long-running operation. +const tokenExpirySafetyMargin = 2 * time.Minute + +// TokenManager provides host-side token validation and (for Lark) automatic +// refresh. It reads from and writes to the vault. +type TokenManager struct { + vs VaultStore +} + +// NewTokenManager constructs a TokenManager backed by vs. +func NewTokenManager(vs VaultStore) *TokenManager { + return &TokenManager{vs: vs} +} + +// GetGHToken returns a valid GitHub access token for userID. +// GitHub tokens obtained via device flow do not expire in standard flow, so +// this simply returns whatever is in the vault bundle. +func (m *TokenManager) GetGHToken(ctx context.Context, userID int64) (string, error) { + bundle, err := LoadGHBundle(ctx, m.vs, userID) + if err != nil { + return "", fmt.Errorf("oauthcli: get gh token: %w", err) + } + if bundle == nil { + return "", fmt.Errorf("oauthcli: get gh token: user %d has not connected GitHub", userID) + } + if bundle.AccessToken == "" { + return "", fmt.Errorf("oauthcli: get gh token: empty access token in vault for user %d", userID) + } + return bundle.AccessToken, nil +} + +// GetLarkRuntimeEnv returns the environment variables needed for lark-cli. +// It loads the Lark bundle, refreshes the access token if expired (using the +// bundle's stored AppSecret), and returns a map ready for env injection. +// +// If the bundle is absent or fully expired (refresh token expired), it returns +// a descriptive error so the runner can skip Lark env injection without failing +// the entire session. +func (m *TokenManager) GetLarkRuntimeEnv(ctx context.Context, userID int64, appID, brand string) (map[string]string, error) { + bundle, err := LoadLarkBundle(ctx, m.vs, userID) + if err != nil { + return nil, fmt.Errorf("oauthcli: get lark env: %w", err) + } + if bundle == nil { + return nil, fmt.Errorf("oauthcli: get lark env: user %d has not connected Lark/Feishu", userID) + } + + now := time.Now() + + // Check refresh token expiry first — if this is gone there's nothing we can do. + if now.After(bundle.RefreshExpiresAt.Add(-tokenExpirySafetyMargin)) { + return nil, fmt.Errorf( + "oauthcli: get lark env: user %d Lark refresh token expired at %s; please re-authorize", + userID, bundle.RefreshExpiresAt.Format(time.RFC3339), + ) + } + + // Refresh the access token if it is expired or about to expire. + if now.After(bundle.AccessExpiresAt.Add(-tokenExpirySafetyMargin)) { + refreshed, err := m.refreshLarkToken(ctx, bundle) + if err != nil { + return nil, fmt.Errorf("oauthcli: get lark env: refresh: %w", err) + } + if err := SaveLarkBundle(ctx, m.vs, userID, *refreshed); err != nil { + return nil, fmt.Errorf("oauthcli: get lark env: save refreshed bundle: %w", err) + } + bundle = refreshed + } + + env := map[string]string{ + "LARK_ACCESS_TOKEN": bundle.AccessToken, + "LARK_APP_ID": bundle.AppID, + "LARK_BRAND": bundle.Brand, + } + return env, nil +} + +// larkRefreshResponse is the JSON body from the OIDC refresh_access_token endpoint. +type larkRefreshResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + } `json:"data"` +} + +// refreshLarkToken exchanges the bundle's refresh token for a new access +// token, returning an updated bundle. The original bundle is not mutated. +func (m *TokenManager) refreshLarkToken(ctx context.Context, bundle *LarkOAuthBundle) (*LarkOAuthBundle, error) { + base := larkBaseFeishu + if bundle.Brand == "lark" { + base = larkBaseLark + } + + // Obtain a fresh app access token using the stored credentials. + appTokenEndpoint := base + "/open-apis/auth/v3/app_access_token/internal" + appTokenBody := map[string]string{ + "app_id": bundle.AppID, + "app_secret": bundle.AppSecret, + } + var appTokenResp larkAppTokenResponse + if err := postJSON(ctx, appTokenEndpoint, appTokenBody, nil, &appTokenResp); err != nil { + return nil, fmt.Errorf("fetch app access token: %w", err) + } + if appTokenResp.Code != 0 { + return nil, fmt.Errorf("app_access_token error %d: %s", appTokenResp.Code, appTokenResp.Msg) + } + + // Refresh the user access token. + refreshEndpoint := base + "/open-apis/authen/v1/oidc/refresh_access_token" + refreshBody := map[string]string{ + "grant_type": "refresh_token", + "refresh_token": bundle.RefreshToken, + } + headers := map[string]string{ + "Authorization": "Bearer " + appTokenResp.AppAccessToken, + } + var refreshResp larkRefreshResponse + if err := postJSON(ctx, refreshEndpoint, refreshBody, headers, &refreshResp); err != nil { + return nil, fmt.Errorf("refresh token request: %w", err) + } + if refreshResp.Code != 0 { + return nil, fmt.Errorf("refresh token error %d: %s", refreshResp.Code, refreshResp.Msg) + } + + now := time.Now() + refreshed := *bundle // shallow copy; all fields are value types or immutable strings + refreshed.AccessToken = refreshResp.Data.AccessToken + if refreshResp.Data.RefreshToken != "" { + refreshed.RefreshToken = refreshResp.Data.RefreshToken + } + refreshed.AccessExpiresAt = now.Add(time.Duration(refreshResp.Data.ExpiresIn) * time.Second) + if refreshResp.Data.RefreshExpiresIn > 0 { + refreshed.RefreshExpiresAt = now.Add(time.Duration(refreshResp.Data.RefreshExpiresIn) * time.Second) + } + return &refreshed, nil +} diff --git a/internal/oauthcli/types.go b/internal/oauthcli/types.go new file mode 100644 index 00000000..25b5c77f --- /dev/null +++ b/internal/oauthcli/types.go @@ -0,0 +1,64 @@ +package oauthcli + +import "time" + +// Provider identifies an OAuth provider. +type Provider string + +const ( + ProviderGitHub Provider = "github" + ProviderLark Provider = "lark" +) + +// FlowState is the lifecycle state of a device-flow authorization. +type FlowState string + +const ( + FlowStatePending FlowState = "pending" + FlowStateAuthorized FlowState = "authorized" + FlowStateFailed FlowState = "failed" + FlowStateExpired FlowState = "expired" +) + +// FlowStatus is the public view of an in-flight device-flow session. +type FlowStatus struct { + Provider Provider + FlowID string + VerificationURI string + UserCode string + ExpiresAt time.Time + State FlowState +} + +// GHOAuthBundle is the versioned vault payload for GitHub. +// GitHub personal access tokens from device flow don't expire by default, +// but we carry optional refresh fields for future fine-grained tokens. +type GHOAuthBundle struct { + Version int `json:"version"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// LarkOAuthBundle is the versioned vault payload for Lark/Feishu. +// AppSecret is stored here (already encrypted at rest in vault) so the +// TokenManager can refresh tokens without needing the app credentials +// passed in at call time. +type LarkOAuthBundle struct { + Version int `json:"version"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + Brand string `json:"brand"` // "lark" or "feishu" + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccessExpiresAt time.Time `json:"access_expires_at"` + RefreshExpiresAt time.Time `json:"refresh_expires_at"` +} + +// Vault key names for the two supported providers. +const ( + VaultKeyGitHub = "GH_OAUTH" + VaultKeyLark = "LARK_CLI_OAUTH" +) diff --git a/internal/oauthcli/vault.go b/internal/oauthcli/vault.go new file mode 100644 index 00000000..96dcf421 --- /dev/null +++ b/internal/oauthcli/vault.go @@ -0,0 +1,82 @@ +package oauthcli + +import ( + "context" + "encoding/json" + "fmt" +) + +// VaultStore is the narrow interface this package needs from the vault service. +type VaultStore interface { + Set(ctx context.Context, userID int64, name string, plaintext string) error + Delete(ctx context.Context, userID int64, name string) error + LoadEnv(ctx context.Context, userID int64) (map[string]string, error) +} + +// SaveGHBundle serializes bundle to JSON and stores it under VaultKeyGitHub. +func SaveGHBundle(ctx context.Context, vs VaultStore, userID int64, bundle GHOAuthBundle) error { + data, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("oauthcli: marshal gh bundle: %w", err) + } + if err := vs.Set(ctx, userID, VaultKeyGitHub, string(data)); err != nil { + return fmt.Errorf("oauthcli: save gh bundle: %w", err) + } + return nil +} + +// LoadGHBundle retrieves and deserializes the GitHub token bundle for userID. +// Returns nil, nil if no entry exists yet. +func LoadGHBundle(ctx context.Context, vs VaultStore, userID int64) (*GHOAuthBundle, error) { + env, err := vs.LoadEnv(ctx, userID) + if err != nil { + return nil, fmt.Errorf("oauthcli: load gh bundle: %w", err) + } + raw, ok := env[VaultKeyGitHub] + if !ok { + return nil, nil + } + var bundle GHOAuthBundle + if err := json.Unmarshal([]byte(raw), &bundle); err != nil { + return nil, fmt.Errorf("oauthcli: unmarshal gh bundle: %w", err) + } + return &bundle, nil +} + +// SaveLarkBundle serializes bundle to JSON and stores it under VaultKeyLark. +func SaveLarkBundle(ctx context.Context, vs VaultStore, userID int64, bundle LarkOAuthBundle) error { + data, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("oauthcli: marshal lark bundle: %w", err) + } + if err := vs.Set(ctx, userID, VaultKeyLark, string(data)); err != nil { + return fmt.Errorf("oauthcli: save lark bundle: %w", err) + } + return nil +} + +// LoadLarkBundle retrieves and deserializes the Lark token bundle for userID. +// Returns nil, nil if no entry exists yet. +func LoadLarkBundle(ctx context.Context, vs VaultStore, userID int64) (*LarkOAuthBundle, error) { + env, err := vs.LoadEnv(ctx, userID) + if err != nil { + return nil, fmt.Errorf("oauthcli: load lark bundle: %w", err) + } + raw, ok := env[VaultKeyLark] + if !ok { + return nil, nil + } + var bundle LarkOAuthBundle + if err := json.Unmarshal([]byte(raw), &bundle); err != nil { + return nil, fmt.Errorf("oauthcli: unmarshal lark bundle: %w", err) + } + return &bundle, nil +} + +// DeleteBundle removes the vault entry identified by key for userID. +func DeleteBundle(ctx context.Context, vs VaultStore, userID int64, key string) error { + if err := vs.Delete(ctx, userID, key); err != nil { + return fmt.Errorf("oauthcli: delete bundle %q: %w", key, err) + } + return nil +} From 1110eaf7f3ea2cf3c589d18ee337ca611903cfa9 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 21 Apr 2026 23:51:16 +0800 Subject: [PATCH 03/21] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20OAuth=20CLI=20p?= =?UTF-8?q?rofile=20API=20routes=20and=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds device-flow OAuth connect/disconnect for GitHub and Lark/Feishu on the user profile page, with in-memory broker caching and vault persistence. Assisted-by: claude-code:claude-sonnet-4-6 --- internal/admin/oauth.go | 329 +++++++++++++++++++ internal/admin/routes.go | 7 + internal/admin/server.go | 11 + internal/admin/ui/pages/profile.templ | 80 +++++ internal/admin/ui/pages/profile_templ.go | 2 +- internal/admin/ui/static/js/pages/profile.js | 72 ++++ 6 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 internal/admin/oauth.go diff --git a/internal/admin/oauth.go b/internal/admin/oauth.go new file mode 100644 index 00000000..54e0ef5f --- /dev/null +++ b/internal/admin/oauth.go @@ -0,0 +1,329 @@ +package admin + +import ( + "context" + "fmt" + "net/http" + + "github.com/vaayne/anna/internal/oauthcli" +) + +const ( + pluginIDGitHub = "auth/github" + pluginIDLark = "auth/lark" +) + +// larkCallbackPath is the path Lark redirects back to after user authorization. +// It must match the redirect_uri configured in the Lark app. +const larkCallbackPath = "/api/auth/profile/oauth/lark/callback" + +// getGitHubBroker returns the cached GitHubBroker, lazily constructing it from +// the current plugin config. Returns an error if the plugin is not configured. +func (s *Server) getGitHubBroker(ctx context.Context) (*oauthcli.GitHubBroker, error) { + state, err := s.pluginHost.Config().Get(ctx, pluginIDGitHub) + if err != nil { + return nil, fmt.Errorf("github plugin config unavailable: %w", err) + } + clientID, _ := state.Config["client_id"].(string) + clientSecret, _ := state.Config["client_secret"].(string) + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("github OAuth app is not configured (set client_id and client_secret in auth/github plugin)") + } + + s.oauthMu.Lock() + defer s.oauthMu.Unlock() + if s.ghBroker == nil || s.ghBrokerClientID != clientID { + s.ghBroker = oauthcli.NewGitHubBroker(oauthcli.GitHubConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + }, s.flowStore) + s.ghBrokerClientID = clientID + } + return s.ghBroker, nil +} + +// getLarkBroker returns the cached LarkBroker, lazily constructing it from the +// current plugin config. Returns an error if the plugin is not configured. +func (s *Server) getLarkBroker(ctx context.Context) (*oauthcli.LarkBroker, error) { + state, err := s.pluginHost.Config().Get(ctx, pluginIDLark) + if err != nil { + return nil, fmt.Errorf("lark plugin config unavailable: %w", err) + } + appID, _ := state.Config["app_id"].(string) + appSecret, _ := state.Config["app_secret"].(string) + brand, _ := state.Config["brand"].(string) + if appID == "" || appSecret == "" { + return nil, fmt.Errorf("lark OAuth app is not configured (set app_id and app_secret in auth/lark plugin)") + } + if brand == "" { + brand = "lark" + } + + s.oauthMu.Lock() + defer s.oauthMu.Unlock() + if s.larkBroker == nil || s.larkBrokerAppID != appID { + redirectURI := s.corsOriginV + larkCallbackPath + s.larkBroker = oauthcli.NewLarkBroker(oauthcli.LarkConfig{ + AppID: appID, + AppSecret: appSecret, + Brand: brand, + }, s.flowStore).WithRedirectURI(redirectURI) + s.larkBrokerAppID = appID + } + return s.larkBroker, nil +} + +// flowStatusJSON is the wire representation of an in-flight OAuth flow. +type flowStatusJSON struct { + Provider string `json:"provider"` + FlowID string `json:"flow_id"` + VerificationURI string `json:"verification_uri"` + UserCode string `json:"user_code,omitempty"` + ExpiresAt string `json:"expires_at"` + State string `json:"state"` +} + +func toFlowStatusJSON(fs oauthcli.FlowStatus) flowStatusJSON { + return flowStatusJSON{ + Provider: string(fs.Provider), + FlowID: fs.FlowID, + VerificationURI: fs.VerificationURI, + UserCode: fs.UserCode, + ExpiresAt: fs.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"), + State: string(fs.State), + } +} + +// startOAuthFlow handles POST /api/auth/profile/oauth/{provider}/start. +func (s *Server) startOAuthFlow(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + ctx := r.Context() + + switch provider { + case "github": + broker, err := s.getGitHubBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.StartDeviceFlow(ctx, info.UserID) + if err != nil { + s.log.Error("start github device flow", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to start GitHub device flow") + return + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + case "lark": + broker, err := s.getLarkBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.StartDeviceFlow(ctx, info.UserID) + if err != nil { + s.log.Error("start lark device flow", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to start Lark device flow") + return + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + } +} + +// pollOAuthFlow handles GET /api/auth/profile/oauth/{provider}/status/{flowID}. +// For GitHub, if the flow is authorized this handler also calls Complete to +// persist the token bundle to vault. +func (s *Server) pollOAuthFlow(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + flowID := r.PathValue("flowID") + ctx := r.Context() + + switch provider { + case "github": + broker, err := s.getGitHubBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.Poll(ctx, flowID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + // Persist token as soon as authorized. + if status.State == oauthcli.FlowStateAuthorized { + if cerr := broker.Complete(ctx, s.vaultSvc, info.UserID, flowID); cerr != nil { + s.log.Error("complete github flow", "user_id", info.UserID, "flow_id", flowID, "error", cerr) + writeError(w, http.StatusInternalServerError, "failed to save GitHub credentials") + return + } + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + case "lark": + broker, err := s.getLarkBroker(ctx) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + status, err := broker.Poll(ctx, flowID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeData(w, http.StatusOK, toFlowStatusJSON(status)) + + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + } +} + +// getOAuthConnected handles GET /api/auth/profile/oauth/{provider}/connected. +func (s *Server) getOAuthConnected(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + ctx := r.Context() + + type connectedResp struct { + Connected bool `json:"connected"` + Username string `json:"username,omitempty"` + } + + switch provider { + case "github": + bundle, err := oauthcli.LoadGHBundle(ctx, s.vaultSvc, info.UserID) + if err != nil { + s.log.Error("load gh bundle", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if bundle == nil { + writeData(w, http.StatusOK, connectedResp{Connected: false}) + return + } + writeData(w, http.StatusOK, connectedResp{Connected: true}) + + case "lark": + bundle, err := oauthcli.LoadLarkBundle(ctx, s.vaultSvc, info.UserID) + if err != nil { + s.log.Error("load lark bundle", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if bundle == nil { + writeData(w, http.StatusOK, connectedResp{Connected: false}) + return + } + label := bundle.AppID + if bundle.Brand != "" { + label = bundle.Brand + ":" + bundle.AppID + } + writeData(w, http.StatusOK, connectedResp{Connected: true, Username: label}) + + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + } +} + +// disconnectOAuth handles DELETE /api/auth/profile/oauth/{provider}. +func (s *Server) disconnectOAuth(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + writeError(w, http.StatusServiceUnavailable, "vault not configured") + return + } + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + provider := r.PathValue("provider") + ctx := r.Context() + + var key string + switch provider { + case "github": + key = oauthcli.VaultKeyGitHub + case "lark": + key = oauthcli.VaultKeyLark + default: + writeError(w, http.StatusBadRequest, "unsupported provider: "+provider) + return + } + + if err := oauthcli.DeleteBundle(ctx, s.vaultSvc, info.UserID, key); err != nil { + s.log.Error("disconnect oauth", "provider", provider, "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to disconnect") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// larkOAuthCallback handles GET /api/auth/profile/oauth/lark/callback. +// Lark redirects the browser here after the user authorizes the app. +// Query params: code=&state= +func (s *Server) larkOAuthCallback(w http.ResponseWriter, r *http.Request) { + if s.vaultSvc == nil { + http.Error(w, "vault not configured", http.StatusServiceUnavailable) + return + } + info := UserFromContext(r.Context()) + if info == nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + code := r.URL.Query().Get("code") + flowID := r.URL.Query().Get("state") + if code == "" || flowID == "" { + http.Error(w, "missing code or state", http.StatusBadRequest) + return + } + + ctx := r.Context() + broker, err := s.getLarkBroker(ctx) + if err != nil { + http.Error(w, "lark not configured", http.StatusServiceUnavailable) + return + } + + if err := broker.Complete(ctx, s.vaultSvc, info.UserID, flowID, code); err != nil { + s.log.Error("lark oauth complete", "user_id", info.UserID, "flow_id", flowID, "error", err) + http.Error(w, "failed to complete Lark authorization: "+err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/profile", http.StatusFound) +} diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 0d29fee0..179c5e05 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -49,6 +49,13 @@ func (s *Server) registerProfileRoutes() { s.mux.HandleFunc("PUT /api/auth/profile/vault/{name}", s.setVaultEntry) s.mux.HandleFunc("DELETE /api/auth/profile/vault/{name}", s.deleteVaultEntry) + // OAuth CLI device-flow (connect/disconnect GitHub and Lark credentials). + s.mux.HandleFunc("POST /api/auth/profile/oauth/{provider}/start", s.startOAuthFlow) + s.mux.HandleFunc("GET /api/auth/profile/oauth/{provider}/status/{flowID}", s.pollOAuthFlow) + s.mux.HandleFunc("GET /api/auth/profile/oauth/{provider}/connected", s.getOAuthConnected) + s.mux.HandleFunc("DELETE /api/auth/profile/oauth/{provider}", s.disconnectOAuth) + s.mux.HandleFunc("GET /api/auth/profile/oauth/lark/callback", s.larkOAuthCallback) + // Self-service user skills. s.mux.HandleFunc("GET /api/auth/profile/skills", s.listProfileSkills) s.mux.HandleFunc("POST /api/auth/profile/skills/install", s.installProfileSkill) diff --git a/internal/admin/server.go b/internal/admin/server.go index d971bbe3..fe4e39f8 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -5,12 +5,14 @@ import ( "database/sql" "log/slog" "net/http" + "sync" "filippo.io/age" "github.com/vaayne/anna/internal/agent" "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/config" + "github.com/vaayne/anna/internal/oauthcli" "github.com/vaayne/anna/internal/pluginhost" "github.com/vaayne/anna/internal/vault" "github.com/vaayne/anna/pkg/db/sqlc" @@ -34,6 +36,14 @@ type Server struct { corsOriginV string // cached CORS origin vaultRecipient *age.X25519Recipient // optional; if set, age keys are generated for new users vaultSvc *vault.Service // optional; if nil, vault endpoints return 503 + + // OAuth CLI device-flow state. + flowStore *oauthcli.FlowStore + oauthMu sync.Mutex + ghBroker *oauthcli.GitHubBroker // lazily initialised; guarded by oauthMu + ghBrokerClientID string // tracks which client_id ghBroker was built with + larkBroker *oauthcli.LarkBroker // lazily initialised; guarded by oauthMu + larkBrokerAppID string // tracks which app_id larkBroker was built with } // New creates an admin server with all API routes mounted. @@ -64,6 +74,7 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine mux: http.NewServeMux(), log: slog.With("component", "admin"), corsOriginV: corsOrigin, + flowStore: oauthcli.NewFlowStore(), } s.registerRoutes() diff --git a/internal/admin/ui/pages/profile.templ b/internal/admin/ui/pages/profile.templ index 3ff74208..5c2daaf0 100644 --- a/internal/admin/ui/pages/profile.templ +++ b/internal/admin/ui/pages/profile.templ @@ -18,6 +18,86 @@ templ ProfilePage() { + +
+

OAuth CLI Credentials

+
+
+

Connect your GitHub or Lark/Feishu account so anna can act on your behalf in CLI tools and runners.

+
+ +
+
+ GitHub + + + +
+
+ + +
+
+ + + +
+
+ Lark / Feishu + + + +
+
+ + +
+
+ + +
+
+
+

Secret Vault

diff --git a/internal/admin/ui/pages/profile_templ.go b/internal/admin/ui/pages/profile_templ.go index 583ae04c..c87a7431 100644 --- a/internal/admin/ui/pages/profile_templ.go +++ b/internal/admin/ui/pages/profile_templ.go @@ -39,7 +39,7 @@ func ProfilePage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

My Skills

User-scope skills available in your conversations. Not shared with other users.

Secret Vault

Encrypted secrets injected as environment variables in sandbox sessions.

0\">
NameCreatedUpdated

No secrets stored yet.

Add Secret

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

My Skills

User-scope skills available in your conversations. Not shared with other users.

OAuth CLI Credentials

Connect your GitHub or Lark/Feishu account so anna can act on your behalf in CLI tools and runners.

GitHub
Lark / Feishu

Secret Vault

Encrypted secrets injected as environment variables in sandbox sessions.

0\">
NameCreatedUpdated

No secrets stored yet.

Add Secret

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/admin/ui/static/js/pages/profile.js b/internal/admin/ui/static/js/pages/profile.js index a231443c..ec16db9e 100644 --- a/internal/admin/ui/static/js/pages/profile.js +++ b/internal/admin/ui/static/js/pages/profile.js @@ -26,9 +26,18 @@ export function register(Alpine) { newSecretName: '', newSecretValue: '', + // OAuth CLI + oauthStatus: { github: 'checking', lark: 'checking' }, + oauthFlow: { github: null, lark: null }, + oauthFlowActive: { github: false, lark: false }, + async init() { await this.loadMySkillsCount() await this.loadVaultEntries() + await Promise.all([ + this.checkOAuthConnected('github'), + this.checkOAuthConnected('lark'), + ]) }, formatTime, @@ -107,6 +116,69 @@ export function register(Alpine) { if (wasOpen) this.loadMySkillsCount() }, + // --- OAuth CLI helpers --- + + async checkOAuthConnected(provider) { + this.oauthStatus[provider] = 'checking' + try { + const data = await api('GET', `/api/auth/profile/oauth/${provider}/connected`) + this.oauthStatus[provider] = data && data.connected ? 'connected' : 'disconnected' + } catch (_) { + this.oauthStatus[provider] = 'disconnected' + } + }, + + async connectOAuth(provider) { + this.oauthFlowActive[provider] = true + this.oauthFlow[provider] = null + try { + const flow = await api('POST', `/api/auth/profile/oauth/${provider}/start`) + this.oauthFlow[provider] = flow + // Poll until terminal state. + await this._pollUntilDone(provider, flow.flow_id) + } catch (e) { + this.$store.toast.show(e.message, 'error') + } finally { + this.oauthFlowActive[provider] = false + this.oauthFlow[provider] = null + await this.checkOAuthConnected(provider) + } + }, + + async _pollUntilDone(provider, flowID) { + const interval = provider === 'github' ? 5000 : 3000 + while (true) { + await new Promise(r => setTimeout(r, interval)) + let status + try { + status = await api('GET', `/api/auth/profile/oauth/${provider}/status/${flowID}`) + } catch (_) { + break + } + if (!status || status.state !== 'pending') { + if (status && status.state === 'authorized') { + this.$store.toast.show(`${provider} connected successfully`) + } else if (status) { + this.$store.toast.show(`${provider} authorization ${status.state}`, 'error') + } + break + } + } + }, + + async disconnectOAuth(provider) { + if (!confirm(`Disconnect ${provider} credentials?`)) return + try { + await api('DELETE', `/api/auth/profile/oauth/${provider}`) + this.$store.toast.show(`${provider} disconnected`) + await this.checkOAuthConnected(provider) + } catch (e) { + this.$store.toast.show(e.message, 'error') + } + }, + + // --- End OAuth CLI helpers --- + async changePassword() { if (!this.currentPassword || !this.newPassword) { this.$store.toast.show('Please fill in all password fields', 'error') From dd8cdfa6b3f01fab6d87c21fe9f07aa3034e21ba Mon Sep 17 00:00:00 2001 From: Vaayne Date: Tue, 21 Apr 2026 23:58:41 +0800 Subject: [PATCH 04/21] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20CLI=20wrapper?= =?UTF-8?q?=20provisioning=20and=20runner=20OAuth=20env=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/cliwrap package with EnsureWrappers to write POSIX shell wrappers for gh and lark-cli under UserRoot/.anna/bin/ - Extend GoRunnerConfig with TokenManager, LarkAppID, LarkBrand fields - buildSandboxEnv strips GH_OAUTH/LARK_CLI_OAUTH bundle keys from sandbox env and injects GH_TOKEN and Lark runtime vars via TokenManager - Set ANNA_GH_BIN/ANNA_LARK_BIN from binaries.ToolPath so wrappers can locate real binaries without PATH ambiguity - createLocalSession provisions wrappers and prepends wrapper dir to PATH - createDockerSession provisions wrappers and sets ANNA_WRAPPER_DIR for Phase 5 PATH injection - Wire TokenManager through PoolManager (auto-constructed from VaultStore when SetVaultEnvLoader is called) and NewRunnerFactory Assisted-by: claude-code:claude-sonnet-4-6 --- cmd/anna/commands.go | 2 +- cmd/anna/commands_test.go | 6 +- internal/agent/factory.go | 4 +- internal/agent/pool_manager.go | 16 +++++- internal/agent/runner/gorunner.go | 4 ++ internal/agent/runner/sandbox_backend.go | 70 +++++++++++++++++++++++- internal/cliwrap/wrap.go | 38 +++++++++++++ 7 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 internal/cliwrap/wrap.go diff --git a/cmd/anna/commands.go b/cmd/anna/commands.go index 0835e852..8e3e9d48 100644 --- a/cmd/anna/commands.go +++ b/cmd/anna/commands.go @@ -385,7 +385,7 @@ func modelSwitcher(base *config.Snapshot, store config.Store, pool *agent.Pool, snap.Providers = providers } - factory, err := agent.NewRunnerFactory(&snap, builtinTools, pluginToolsBuilder, providerRegistryBuilder, promptToolsFn, promptSectionsFn, toolLifecycle, skillStore, nil, nil) + factory, err := agent.NewRunnerFactory(&snap, builtinTools, pluginToolsBuilder, providerRegistryBuilder, promptToolsFn, promptSectionsFn, toolLifecycle, skillStore, nil, nil, nil) if err != nil { return err } diff --git a/cmd/anna/commands_test.go b/cmd/anna/commands_test.go index dd906759..86d8ce05 100644 --- a/cmd/anna/commands_test.go +++ b/cmd/anna/commands_test.go @@ -162,7 +162,7 @@ func TestNewRunnerFactoryGo(t *testing.T) { } snap.Workspace = t.TempDir() - factory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil) + factory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("NewRunnerFactory: %v", err) } @@ -182,7 +182,7 @@ func TestNewRunnerFactoryUnknown(t *testing.T) { Runner: config.RunnerConfig{Type: "invalid"}, } - _, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil) + _, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil, nil) if err == nil { t.Fatal("expected error for unknown runner type") } @@ -290,7 +290,7 @@ func TestModelSwitcherPreservesPromptBuilders(t *testing.T) { } snap.Workspace = t.TempDir() - initialFactory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil) + initialFactory, err := agent.NewRunnerFactory(snap, nil, nil, testProviderRegistryBuilder, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("NewRunnerFactory: %v", err) } diff --git a/internal/agent/factory.go b/internal/agent/factory.go index a7808a89..a43b6cd1 100644 --- a/internal/agent/factory.go +++ b/internal/agent/factory.go @@ -8,6 +8,7 @@ import ( "github.com/vaayne/anna/internal/agent/runner" "github.com/vaayne/anna/internal/config" + "github.com/vaayne/anna/internal/oauthcli" coreagent "github.com/vaayne/anna/pkg/agent" "github.com/vaayne/anna/pkg/hooks" "github.com/vaayne/anna/pkg/memory" @@ -25,7 +26,7 @@ import ( // // Hooks are not part of the factory — they are injected via RunnerParams.HooksFn // by the Pool, keeping hook lifecycle fully decoupled from model/provider config. -func NewRunnerFactory(snap *config.Snapshot, builtinTools []tools.Tool, pluginToolsBuilder PluginToolsBuilder, providerRegistryBuilder func(api, apiKey, baseURL string) (*providers.Registry, error), promptToolsFn func(context.Context) ([]pkgplugins.PromptToolInfo, error), promptSectionsFn func(context.Context, pkgplugins.SystemPromptContext) ([]pkgplugins.SystemPromptSection, error), toolLifecycle *coreagent.ToolLifecycle, skillStore pkgplugins.SkillStore, sandboxBackendFn func(ctx context.Context) string, vaultEnvLoader runner.VaultEnvLoader) (runner.NewRunnerFunc, error) { +func NewRunnerFactory(snap *config.Snapshot, builtinTools []tools.Tool, pluginToolsBuilder PluginToolsBuilder, providerRegistryBuilder func(api, apiKey, baseURL string) (*providers.Registry, error), promptToolsFn func(context.Context) ([]pkgplugins.PromptToolInfo, error), promptSectionsFn func(context.Context, pkgplugins.SystemPromptContext) ([]pkgplugins.SystemPromptSection, error), toolLifecycle *coreagent.ToolLifecycle, skillStore pkgplugins.SkillStore, sandboxBackendFn func(ctx context.Context) string, vaultEnvLoader runner.VaultEnvLoader, tokenManager *oauthcli.TokenManager) (runner.NewRunnerFunc, error) { switch snap.Runner.Type { case "go": return func(ctx context.Context, params runner.RunnerParams) (runner.Runner, error) { @@ -131,6 +132,7 @@ func NewRunnerFactory(snap *config.Snapshot, builtinTools []tools.Tool, pluginTo Providers: providerRegistryBuilder, UserID: params.UserID, VaultEnvLoader: vaultEnvLoader, + TokenManager: tokenManager, }) }, nil default: diff --git a/internal/agent/pool_manager.go b/internal/agent/pool_manager.go index 75cba620..6905c28d 100644 --- a/internal/agent/pool_manager.go +++ b/internal/agent/pool_manager.go @@ -10,6 +10,7 @@ import ( "github.com/vaayne/anna/internal/agent/runner" "github.com/vaayne/anna/internal/config" + "github.com/vaayne/anna/internal/oauthcli" coreagent "github.com/vaayne/anna/pkg/agent" "github.com/vaayne/anna/pkg/hooks" "github.com/vaayne/anna/pkg/memory" @@ -131,6 +132,13 @@ func WithVaultEnvLoader(v runner.VaultEnvLoader) PoolManagerOption { } } +// WithTokenManager sets the OAuth token manager for runtime token injection. +func WithTokenManager(tm *oauthcli.TokenManager) PoolManagerOption { + return func(pm *PoolManager) { + pm.tokenManager = tm + } +} + // PoolManager manages a map of agent ID to Pool. It reads enabled agents // from the config Store and creates one Pool per agent. type PoolManager struct { @@ -152,6 +160,7 @@ type PoolManager struct { builtinToolsFactory BuiltinToolsFactory skillStore pkgplugins.SkillStore vaultEnvLoader runner.VaultEnvLoader + tokenManager *oauthcli.TokenManager log *slog.Logger } @@ -172,9 +181,14 @@ func NewPoolManager(store config.Store, mem memory.Provider, opts ...PoolManager // SetVaultEnvLoader sets the vault env loader and rebuilds all pool factories // so existing pools pick up the loader. Must be called after StartAll. +// If vs also satisfies oauthcli.VaultStore, a TokenManager is constructed and +// wired into the pool manager so runners can inject runtime OAuth tokens. func (pm *PoolManager) SetVaultEnvLoader(ctx context.Context, v runner.VaultEnvLoader) { pm.mu.Lock() pm.vaultEnvLoader = v + if vs, ok := v.(oauthcli.VaultStore); ok { + pm.tokenManager = oauthcli.NewTokenManager(vs) + } pools := make(map[string]*Pool, len(pm.pools)) maps.Copy(pools, pm.pools) pm.mu.Unlock() @@ -440,7 +454,7 @@ func (pm *PoolManager) buildFactory(_ context.Context, snap *config.Snapshot) (r plugins, _ := pm.store.ListPlugins(ctx) return config.ActiveSandboxBackend(plugins) } - return NewRunnerFactory(snap, builtinTools, pm.pluginToolsBuilder, pm.providerRegistryBuilder, pm.promptToolsBuilder, pm.promptSectionsBuilder, pm.toolLifecycle, pm.skillStore, sandboxBackendFn, pm.vaultEnvLoader) + return NewRunnerFactory(snap, builtinTools, pm.pluginToolsBuilder, pm.providerRegistryBuilder, pm.promptToolsBuilder, pm.promptSectionsBuilder, pm.toolLifecycle, pm.skillStore, sandboxBackendFn, pm.vaultEnvLoader, pm.tokenManager) } // mergeTools creates a new slice containing builtin tools followed by more diff --git a/internal/agent/runner/gorunner.go b/internal/agent/runner/gorunner.go index ec20ada2..17cba6be 100644 --- a/internal/agent/runner/gorunner.go +++ b/internal/agent/runner/gorunner.go @@ -9,6 +9,7 @@ import ( "time" "github.com/vaayne/anna/internal/config" + "github.com/vaayne/anna/internal/oauthcli" builtinres "github.com/vaayne/anna/internal/resources" "github.com/vaayne/anna/internal/resources/binaries" coreagent "github.com/vaayne/anna/pkg/agent" @@ -58,6 +59,9 @@ type GoRunnerConfig struct { SandboxBackendFn func(ctx context.Context) string // resolves active backend at session time; overrides Sandbox.Backend UserID int64 // auth user ID; used for vault secret injection VaultEnvLoader VaultEnvLoader // optional; if set, vault secrets are injected into sandbox env + TokenManager *oauthcli.TokenManager // optional; if set, runtime OAuth tokens are injected into sandbox env + LarkAppID string // optional Lark app_id from plugin config + LarkBrand string // optional Lark brand: "lark" or "feishu" } // GoRunner implements Runner by calling LLM providers directly via agent.Runner. diff --git a/internal/agent/runner/sandbox_backend.go b/internal/agent/runner/sandbox_backend.go index 5893d51e..fe1f965c 100644 --- a/internal/agent/runner/sandbox_backend.go +++ b/internal/agent/runner/sandbox_backend.go @@ -6,12 +6,16 @@ import ( "log/slog" "maps" "os" + "path/filepath" "sync" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/vaayne/anna/internal/cliwrap" "github.com/vaayne/anna/internal/config" + "github.com/vaayne/anna/internal/oauthcli" + "github.com/vaayne/anna/internal/resources/binaries" "github.com/vaayne/anna/internal/sandbox" dockerplugin "github.com/vaayne/anna/plugins/sandbox/docker" "github.com/vaayne/anna/plugins/sandbox/docker/dockerclient" @@ -138,6 +142,43 @@ func buildSandboxEnv(ctx context.Context, cfg GoRunnerConfig, paths sandboxPaths } } + // OAuth bundle keys are host-side only: they hold raw JSON credentials and + // must not reach the sandbox process. The runner injects derived runtime + // tokens below instead. + delete(env, oauthcli.VaultKeyGitHub) + delete(env, oauthcli.VaultKeyLark) + + // Inject runtime OAuth tokens when a TokenManager is available. + if cfg.TokenManager != nil { + if token, err := cfg.TokenManager.GetGHToken(ctx, cfg.UserID); err == nil && token != "" { + env["GH_TOKEN"] = token + } else if err != nil { + slog.Debug("gh token injection skipped", + "component", "runner_sandbox", + "user_id", cfg.UserID, + "error", err, + ) + } + + if larkEnv, err := cfg.TokenManager.GetLarkRuntimeEnv(ctx, cfg.UserID, cfg.LarkAppID, cfg.LarkBrand); err == nil { + maps.Copy(env, larkEnv) + } else { + slog.Debug("lark env injection skipped", + "component", "runner_sandbox", + "user_id", cfg.UserID, + "error", err, + ) + } + } + + // Set real binary paths so wrapper scripts can locate them. + if ghPath := binaries.ToolPath(paths.AnnaHome, "gh"); ghPath != "" { + env["ANNA_GH_BIN"] = ghPath + } + if larkPath := binaries.ToolPath(paths.AnnaHome, "lark-cli"); larkPath != "" { + env["ANNA_LARK_BIN"] = larkPath + } + // Runner-set vars overlay vault entries so they always take precedence. maps.Copy(env, sandboxProcessEnv(paths)) return env @@ -160,12 +201,23 @@ func createDockerSession(ctx context.Context, cfg GoRunnerConfig) (*runnerSessio recordSandboxError(span, err) return nil, err } + env := buildSandboxEnv(ctx, cfg, paths) + + // Provision CLI wrappers under the user wrapper dir; expose the dir path via + // ANNA_WRAPPER_DIR so Phase 5 can add it to PATH inside the container. + wrapperDir := filepath.Join(paths.UserRoot, ".anna", "bin") + if err := cliwrap.EnsureWrappers(wrapperDir); err != nil { + slog.Warn("cliwrap provision failed", "component", "runner_sandbox", "error", err) + } else { + env["ANNA_WRAPPER_DIR"] = wrapperDir + } + policy := sandbox.Policy{ Filesystem: runnerFilesystemPolicy(paths), Network: sandbox.NetworkPolicy{ Mode: sandbox.NetworkMode(cfg.Sandbox.Network.Mode), }, - Env: buildSandboxEnv(ctx, cfg, paths), + Env: env, InheritEnv: true, } @@ -212,12 +264,26 @@ func createLocalSession(ctx context.Context, cfg GoRunnerConfig) (*runnerSession if err != nil { return nil, fmt.Errorf("resolve sandbox paths: %w", err) } + env := buildSandboxEnv(ctx, cfg, paths) + + // Provision CLI wrappers and prepend wrapper dir to PATH for local sessions. + wrapperDir := filepath.Join(paths.UserRoot, ".anna", "bin") + if err := cliwrap.EnsureWrappers(wrapperDir); err != nil { + slog.Warn("cliwrap provision failed", "component", "runner_sandbox", "error", err) + } else { + existing := env["PATH"] + if existing == "" { + existing = os.Getenv("PATH") + } + env["PATH"] = wrapperDir + string(os.PathListSeparator) + existing + } + policy := sandbox.Policy{ Filesystem: runnerFilesystemPolicy(paths), Network: sandbox.NetworkPolicy{ Mode: sandbox.NetworkMode(cfg.Sandbox.Network.Mode), }, - Env: buildSandboxEnv(ctx, cfg, paths), + Env: env, InheritEnv: true, } diff --git a/internal/cliwrap/wrap.go b/internal/cliwrap/wrap.go new file mode 100644 index 00000000..def28c17 --- /dev/null +++ b/internal/cliwrap/wrap.go @@ -0,0 +1,38 @@ +// Package cliwrap provisions thin POSIX shell wrapper scripts for CLI tools +// (gh, lark-cli) under a user-owned bin directory. The wrappers rely on +// environment variables injected by the runner (ANNA_GH_BIN, ANNA_LARK_BIN) +// to locate the real binaries, avoiding infinite exec loops. +package cliwrap + +import ( + "os" + "path/filepath" +) + +const ( + // ghWrapper is the shell script placed at binDir/gh. + // It delegates to the real binary path exported by the runner as ANNA_GH_BIN. + ghWrapper = "#!/bin/sh\nexec \"${ANNA_GH_BIN:-gh}\" \"$@\"\n" + + // larkWrapper is the shell script placed at binDir/lark-cli. + // It delegates to the real binary path exported by the runner as ANNA_LARK_BIN. + larkWrapper = "#!/bin/sh\nexec \"${ANNA_LARK_BIN:-lark-cli}\" \"$@\"\n" +) + +// EnsureWrappers creates wrapper scripts for gh and lark-cli under binDir. +// binDir is typically UserRoot/.anna/bin/. +// The wrappers are re-written on every call so stale content is never left behind. +func EnsureWrappers(binDir string) error { + if err := os.MkdirAll(binDir, 0o755); err != nil { + return err + } + if err := writeExecutable(filepath.Join(binDir, "gh"), ghWrapper); err != nil { + return err + } + return writeExecutable(filepath.Join(binDir, "lark-cli"), larkWrapper) +} + +// writeExecutable atomically writes content to path with executable permissions. +func writeExecutable(path, content string) error { + return os.WriteFile(path, []byte(content), 0o755) +} From ae4400097a1da7c0853c027d0007daf38877f9d1 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 00:01:35 +0800 Subject: [PATCH 05/21] =?UTF-8?q?=E2=9C=A8=20feat:=20inject=20wrapper=20PA?= =?UTF-8?q?TH=20in=20Docker=20sessions=20and=20add=20gh/lark-cli=20to=20sa?= =?UTF-8?q?ndbox=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add injectWrapperPath helper: translates ANNA_WRAPPER_DIR to a container PATH prepend so CLI wrappers provisioned in UserRoot/.anna/bin/ are found by exec - Add injectDockerBinPaths helper: overrides ANNA_GH_BIN/ANNA_LARK_BIN to container-side absolute paths (/usr/bin/gh, /usr/local/bin/lark-cli) so wrapper scripts never loop back through themselves - Apply both helpers in dockerHost.Exec and dockerHost.StartProcess after translateEnvPaths - Dockerfile: install gh 2.89.0 via official apt repo; install lark-cli 1.0.15 from GitHub release tarball Assisted-by: claude-code:claude-sonnet-4-6 --- plugins/sandbox/docker/Dockerfile | 27 ++++++++++++++++++++ plugins/sandbox/docker/session.go | 41 +++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/plugins/sandbox/docker/Dockerfile b/plugins/sandbox/docker/Dockerfile index bfabae42..f75a9e10 100644 --- a/plugins/sandbox/docker/Dockerfile +++ b/plugins/sandbox/docker/Dockerfile @@ -24,6 +24,33 @@ RUN apt-get update && \ ln -s /usr/bin/fdfind /usr/local/bin/fd && \ apt-get clean && rm -rf /var/lib/apt/lists/* +# GitHub CLI (gh) — version pinned to match internal/builddeps/binaries.go ghCLIVersion +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ + apt-get update && \ + apt-get install -y gh=2.89.0 && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# lark-cli — version pinned to match internal/builddeps/binaries.go larkCLIVersion +# Released as a tarball from github.com/larksuite/cli at /usr/local/bin/lark-cli +# (injectDockerBinPaths in plugins/sandbox/docker/session.go relies on this path) +ARG LARK_CLI_VERSION=1.0.15 +RUN set -eux; \ + ARCH="$(dpkg --print-architecture)"; \ + case "$ARCH" in \ + amd64) LARK_ARCH="linux-amd64" ;; \ + arm64) LARK_ARCH="linux-arm64" ;; \ + *) echo "unsupported architecture: $ARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL \ + "https://github.com/larksuite/cli/releases/download/v${LARK_CLI_VERSION}/lark-cli-${LARK_CLI_VERSION}-${LARK_ARCH}.tar.gz" \ + -o /tmp/lark-cli.tar.gz && \ + tar -xzf /tmp/lark-cli.tar.gz -C /tmp && \ + find /tmp -name "lark-cli" -type f -exec install -m 755 {} /usr/local/bin/lark-cli \; && \ + rm -f /tmp/lark-cli.tar.gz + ARG USER_UID=1000 ARG USER_GID=1000 diff --git a/plugins/sandbox/docker/session.go b/plugins/sandbox/docker/session.go index c5227c0e..90d2646f 100644 --- a/plugins/sandbox/docker/session.go +++ b/plugins/sandbox/docker/session.go @@ -227,6 +227,43 @@ func translateEnvPaths(env map[string]string, mountTable []dockerclient.Mount) m return out } +// containerDefaultPATH is the image-baked PATH from the Dockerfile ENV directive. +// It is used as the base when building a container exec PATH that prepends the +// wrapper dir. Keep in sync with the ENV PATH line in plugins/sandbox/docker/Dockerfile. +const containerDefaultPATH = "/home/anna/.local/bin:/home/anna/.local/share/mise/shims:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +// injectWrapperPath prepends the wrapper dir (ANNA_WRAPPER_DIR) to the +// container exec PATH so that CLI wrappers provisioned in that directory +// shadow the real binaries and intercept calls for auth injection. +// +// ANNA_WRAPPER_DIR is translated by translateEnvPaths from a host path to its +// container-side equivalent before this function is called. If the variable is +// absent or empty (translation failed because the dir is not inside a mount), +// the env is returned unchanged. +func injectWrapperPath(env map[string]string) map[string]string { + wrapperDir, ok := env["ANNA_WRAPPER_DIR"] + if !ok || wrapperDir == "" { + return env + } + base := env["PATH"] + if base == "" { + base = containerDefaultPATH + } + env["PATH"] = wrapperDir + ":" + base + return env +} + +// injectDockerBinPaths overrides ANNA_GH_BIN and ANNA_LARK_BIN with their +// absolute container-side paths. The runner sets these to host paths via +// binaries.ToolPath; those paths do not exist inside the container. +// Overriding them here ensures the wrapper scripts exec the real binaries +// rather than looping back through the wrappers on PATH. +func injectDockerBinPaths(env map[string]string) map[string]string { + env["ANNA_GH_BIN"] = "/usr/bin/gh" + env["ANNA_LARK_BIN"] = "/usr/local/bin/lark-cli" + return env +} + // dockerSession is a docker-backed sandbox session backed by a single container. type dockerSession struct { id string @@ -482,7 +519,7 @@ func (h *dockerHost) Exec(ctx context.Context, command string, opts sandboxpkg.E return sandboxpkg.ExecResult{}, fmt.Errorf("docker host exec: cwd not in any mount: %w", err) } - env := translateEnvPaths(mergeEnv(h.session.policy.Env, opts.Env), h.session.mountTable) + env := injectDockerBinPaths(injectWrapperPath(translateEnvPaths(mergeEnv(h.session.policy.Env, opts.Env), h.session.mountTable))) timeout := opts.Timeout if timeout == 0 { @@ -527,7 +564,7 @@ func (h *dockerHost) StartProcess(ctx context.Context, req sandboxpkg.ProcessReq return nil, fmt.Errorf("docker host start_process: cwd not in any mount: %w", err) } - env := translateEnvPaths(mergeEnv(h.session.policy.Env, req.Env), h.session.mountTable) + env := injectDockerBinPaths(injectWrapperPath(translateEnvPaths(mergeEnv(h.session.policy.Env, req.Env), h.session.mountTable))) timeout := req.Timeout if timeout == 0 { From 4247d14685e3421f5aed391bb2aed262826f0a1f Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 00:08:27 +0800 Subject: [PATCH 06/21] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20tests=20and=20d?= =?UTF-8?q?ocs=20for=20OAuth=20CLI=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: claude-code:claude-sonnet-4-6 --- docs/content/docs/features/cli-oauth.md | 68 +++++++ docs/content/docs/features/cli-oauth.zh.md | 50 +++++ docs/content/docs/features/meta.json | 2 +- docs/content/docs/features/meta.zh.json | 2 +- internal/agent/runner/sandbox_backend_test.go | 38 ++++ internal/cliwrap/wrap_test.go | 103 ++++++++++ internal/oauthcli/store_test.go | 108 +++++++++++ internal/oauthcli/vault_test.go | 182 ++++++++++++++++++ .../resources/skills/system/anna/SKILL.md | 1 + plugins/sandbox/docker/session_pure_test.go | 77 ++++++++ 10 files changed, 629 insertions(+), 2 deletions(-) create mode 100644 docs/content/docs/features/cli-oauth.md create mode 100644 docs/content/docs/features/cli-oauth.zh.md create mode 100644 internal/cliwrap/wrap_test.go create mode 100644 internal/oauthcli/store_test.go create mode 100644 internal/oauthcli/vault_test.go diff --git a/docs/content/docs/features/cli-oauth.md b/docs/content/docs/features/cli-oauth.md new file mode 100644 index 00000000..923f211d --- /dev/null +++ b/docs/content/docs/features/cli-oauth.md @@ -0,0 +1,68 @@ +--- +title: CLI OAuth +--- + +## Overview + +The CLI OAuth feature lets agents use `gh` (GitHub CLI) and `lark-cli` directly from +sandbox sessions without manual authentication. Anna handles the OAuth device flow on +the host, stores a versioned token bundle in your personal vault, and injects a fresh +runtime token into each sandbox environment automatically. + +## Prerequisites + +An admin must configure the relevant auth plugin with application credentials before +any user can connect: + +- **GitHub**: The admin must set a GitHub OAuth app's client ID and secret in the + GitHub auth plugin settings. +- **Lark / Feishu**: The admin must set a Lark app ID, app secret, and brand + (`lark` or `feishu`) in the Lark auth plugin settings. + +## Connecting + +1. Open the Anna admin panel and navigate to your **Profile** page. +2. Find the **OAuth CLI Credentials** section. +3. Click **Connect** next to the provider you want to link. +4. Anna starts a device flow and displays a verification URL and user code. +5. Open the URL in a browser, enter the code, and authorize. +6. Anna polls for completion. Once authorized, the token bundle is saved to your vault. + +You can disconnect at any time by clicking **Disconnect** next to the provider. + +## Using the CLIs + +After connecting, raw `gh` and `lark-cli` commands work inside agent sandbox sessions +without any additional configuration. Anna prepends a wrapper directory to `PATH` so +that every `gh` or `lark-cli` invocation automatically receives the correct credentials. + +Example (issued by the agent inside a bash tool call): + +```sh +gh issue list --repo owner/repo +lark-cli message send --chat-id --text "Hello" +``` + +## Known limitations + +### Lark token expiry + +Lark user access tokens expire after approximately **2 hours**. Anna refreshes them +at session start only. If an agent session outlives the token, `lark-cli` calls will +fail with an authentication error. Starting a new Anna session will pick up a freshly +refreshed token automatically. + +### Restart loses in-flight device flows + +Pending device flows (started but not yet authorized) are held in memory. An Anna +process restart discards them. If Anna restarts while you are completing authorization +in a browser, you will need to start the flow again from the profile page. + +## Security model + +OAuth token bundles (`GH_OAUTH`, `LARK_CLI_OAUTH`) are stored encrypted at rest in +your vault using the same age-based encryption as other vault entries. They are +treated as host-only data: the raw JSON bundles are never forwarded into the sandbox +process environment. Only the derived runtime token (e.g., `GH_TOKEN` for GitHub) is +injected, so sandbox processes never have access to refresh credentials or OAuth app +secrets. diff --git a/docs/content/docs/features/cli-oauth.zh.md b/docs/content/docs/features/cli-oauth.zh.md new file mode 100644 index 00000000..082e472f --- /dev/null +++ b/docs/content/docs/features/cli-oauth.zh.md @@ -0,0 +1,50 @@ +--- +title: CLI OAuth 认证 +--- + +## 概述 + +CLI OAuth 功能允许 Agent 在沙盒会话中直接使用 `gh`(GitHub CLI)和 `lark-cli`,无需手动认证。Anna 在宿主机上完成 OAuth 设备流程,将版本化的令牌包存储到个人密钥库中,并在每次沙盒会话启动时自动注入最新的运行时令牌。 + +## 前提条件 + +用户连接前,管理员须在对应的认证插件中配置好应用凭据: + +- **GitHub**:管理员需在 GitHub 认证插件设置中填入 OAuth 应用的 Client ID 和 Client Secret。 +- **Lark / 飞书**:管理员需在 Lark 认证插件设置中填入 App ID、App Secret 以及品牌标识(`lark` 或 `feishu`)。 + +## 连接步骤 + +1. 打开 Anna 管理面板,进入**个人资料**页面。 +2. 找到 **OAuth CLI 凭据**区域。 +3. 点击要连接的服务商旁边的**连接**按钮。 +4. Anna 启动设备流程,并显示验证 URL 和用户码。 +5. 在浏览器中打开该 URL,输入用户码并完成授权。 +6. Anna 轮询授权结果。授权成功后,令牌包将保存至您的密钥库。 + +随时可点击服务商旁的**断开连接**取消绑定。 + +## 使用 CLI 工具 + +连接成功后,Agent 在沙盒会话中可以直接运行 `gh` 和 `lark-cli` 命令,无需任何额外配置。Anna 会将包装脚本目录添加到 `PATH` 的最前面,使每次调用都自动获得正确的认证凭据。 + +示例(由 Agent 在 bash 工具调用中执行): + +```sh +gh issue list --repo owner/repo +lark-cli message send --chat-id --text "Hello" +``` + +## 已知限制 + +### Lark 令牌有效期 + +Lark 用户访问令牌有效期约为 **2 小时**。Anna 仅在会话启动时刷新令牌。若 Agent 会话时长超过令牌有效期,`lark-cli` 调用将因认证失败而报错。重新开启一个 Anna 会话,即可自动获取刷新后的令牌。 + +### 重启会丢失进行中的设备流程 + +未完成授权的设备流程状态保存在内存中。Anna 进程重启后,这些状态将丢失。如果您正在浏览器中完成授权时 Anna 发生重启,需要在个人资料页面重新发起授权流程。 + +## 安全模型 + +OAuth 令牌包(`GH_OAUTH`、`LARK_CLI_OAUTH`)使用与其他密钥库条目相同的 age 加密方式加密存储。它们仅在宿主机上使用:原始 JSON 包不会传入沙盒进程的环境变量。沙盒进程只能获取派生的运行时令牌(例如 GitHub 的 `GH_TOKEN`),无法访问刷新凭据或 OAuth 应用密钥。 diff --git a/docs/content/docs/features/meta.json b/docs/content/docs/features/meta.json index 485b71ce..4d396450 100644 --- a/docs/content/docs/features/meta.json +++ b/docs/content/docs/features/meta.json @@ -1,4 +1,4 @@ { "title": "Features", - "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault"] + "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault", "cli-oauth"] } diff --git a/docs/content/docs/features/meta.zh.json b/docs/content/docs/features/meta.zh.json index e977c0ec..48e95a85 100644 --- a/docs/content/docs/features/meta.zh.json +++ b/docs/content/docs/features/meta.zh.json @@ -1,4 +1,4 @@ { "title": "功能", - "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault"] + "pages": ["plugin-system", "scheduler-system", "notification-system", "agent-templates", "vault", "cli-oauth"] } diff --git a/internal/agent/runner/sandbox_backend_test.go b/internal/agent/runner/sandbox_backend_test.go index 0a4e3f85..ff3d0625 100644 --- a/internal/agent/runner/sandbox_backend_test.go +++ b/internal/agent/runner/sandbox_backend_test.go @@ -192,3 +192,41 @@ func TestBuildSandboxEnv_noVaultLoader(t *testing.T) { t.Errorf("ANNA_HOME = %q, want %q", got, cfg.AnnaHome) } } + +// TestBuildSandboxEnv_OAuthBundleKeysStripped verifies that vault entries for +// the OAuth bundle keys (GH_OAUTH and LARK_CLI_OAUTH) are not forwarded into +// the sandbox environment, even when present in the vault. +func TestBuildSandboxEnv_OAuthBundleKeysStripped(t *testing.T) { + cfg := GoRunnerConfig{ + AnnaHome: "/anna", + AgentRoot: "/workspace/agent", + UserRoot: "/workspace/users/1", + UserID: 1, + VaultEnvLoader: &stubVaultLoader{ + env: map[string]string{ + "GH_OAUTH": `{"version":1,"access_token":"ghp_secret"}`, + "LARK_CLI_OAUTH": `{"version":1,"access_token":"u-lark-secret"}`, + "OTHER_SECRET": "should-pass-through", + }, + }, + } + + paths, err := resolveSandboxPaths(cfg) + if err != nil { + t.Fatalf("resolveSandboxPaths: %v", err) + } + + env := buildSandboxEnv(context.Background(), cfg, paths) + + if _, ok := env["GH_OAUTH"]; ok { + t.Error("GH_OAUTH must not appear in sandbox env") + } + if _, ok := env["LARK_CLI_OAUTH"]; ok { + t.Error("LARK_CLI_OAUTH must not appear in sandbox env") + } + + // Unrelated vault entries must still pass through. + if got := env["OTHER_SECRET"]; got != "should-pass-through" { + t.Errorf("OTHER_SECRET = %q, want %q", got, "should-pass-through") + } +} diff --git a/internal/cliwrap/wrap_test.go b/internal/cliwrap/wrap_test.go new file mode 100644 index 00000000..85252abe --- /dev/null +++ b/internal/cliwrap/wrap_test.go @@ -0,0 +1,103 @@ +package cliwrap + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureWrappers_CreatesBinDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "newdir", "bin") + + // dir does not exist yet. + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("expected dir to not exist before EnsureWrappers") + } + + if err := EnsureWrappers(dir); err != nil { + t.Fatalf("EnsureWrappers: %v", err) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("Stat after EnsureWrappers: %v", err) + } + if !info.IsDir() { + t.Fatal("expected binDir to be a directory") + } +} + +func TestEnsureWrappers_WritesGhScript(t *testing.T) { + dir := t.TempDir() + + if err := EnsureWrappers(dir); err != nil { + t.Fatalf("EnsureWrappers: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "gh")) + if err != nil { + t.Fatalf("ReadFile gh: %v", err) + } + content := string(data) + if !strings.HasPrefix(content, "#!/bin/sh") { + t.Errorf("gh wrapper does not start with #!/bin/sh, got: %q", content[:min(len(content), 20)]) + } +} + +func TestEnsureWrappers_WritesLarkCliScript(t *testing.T) { + dir := t.TempDir() + + if err := EnsureWrappers(dir); err != nil { + t.Fatalf("EnsureWrappers: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "lark-cli")) + if err != nil { + t.Fatalf("ReadFile lark-cli: %v", err) + } + content := string(data) + if !strings.HasPrefix(content, "#!/bin/sh") { + t.Errorf("lark-cli wrapper does not start with #!/bin/sh, got: %q", content[:min(len(content), 20)]) + } +} + +func TestEnsureWrappers_ScriptsAreExecutable(t *testing.T) { + dir := t.TempDir() + + if err := EnsureWrappers(dir); err != nil { + t.Fatalf("EnsureWrappers: %v", err) + } + + for _, name := range []string{"gh", "lark-cli"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("Stat %s: %v", name, err) + } + if info.Mode()&0o111 == 0 { + t.Errorf("%s is not executable (mode %o)", name, info.Mode()) + } + } +} + +func TestEnsureWrappers_Idempotent(t *testing.T) { + dir := t.TempDir() + + if err := EnsureWrappers(dir); err != nil { + t.Fatalf("EnsureWrappers (first call): %v", err) + } + if err := EnsureWrappers(dir); err != nil { + t.Fatalf("EnsureWrappers (second call): %v", err) + } + + // Content should be identical on both calls. + for _, name := range []string{"gh", "lark-cli"} { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("ReadFile %s: %v", name, err) + } + if !strings.HasPrefix(string(data), "#!/bin/sh") { + t.Errorf("%s wrapper corrupt after second call", name) + } + } +} diff --git a/internal/oauthcli/store_test.go b/internal/oauthcli/store_test.go new file mode 100644 index 00000000..7e4d8b6b --- /dev/null +++ b/internal/oauthcli/store_test.go @@ -0,0 +1,108 @@ +package oauthcli + +import ( + "testing" + "time" +) + +func TestFlowStore_CreateAndGet(t *testing.T) { + s := NewFlowStore() + flow := FlowStatus{ + Provider: ProviderGitHub, + FlowID: "flow-1", + VerificationURI: "https://github.com/login/device", + UserCode: "ABCD-1234", + ExpiresAt: time.Now().Add(15 * time.Minute), + State: FlowStatePending, + } + + s.Create(flow) + + got, ok := s.Get("flow-1") + if !ok { + t.Fatal("Get: expected flow to exist") + } + if got.FlowID != flow.FlowID { + t.Errorf("FlowID = %q, want %q", got.FlowID, flow.FlowID) + } + if got.State != FlowStatePending { + t.Errorf("State = %q, want %q", got.State, FlowStatePending) + } + if got.VerificationURI != flow.VerificationURI { + t.Errorf("VerificationURI = %q, want %q", got.VerificationURI, flow.VerificationURI) + } +} + +func TestFlowStore_GetMissing(t *testing.T) { + s := NewFlowStore() + _, ok := s.Get("nonexistent") + if ok { + t.Fatal("Get: expected false for missing flow") + } +} + +func TestFlowStore_Update(t *testing.T) { + s := NewFlowStore() + s.Create(FlowStatus{ + FlowID: "flow-2", + State: FlowStatePending, + }) + + s.Update("flow-2", FlowStateAuthorized, nil) + + got, ok := s.Get("flow-2") + if !ok { + t.Fatal("Get after Update: expected flow to exist") + } + if got.State != FlowStateAuthorized { + t.Errorf("State = %q, want %q", got.State, FlowStateAuthorized) + } +} + +func TestFlowStore_UpdateWithMutator(t *testing.T) { + s := NewFlowStore() + s.Create(FlowStatus{ + FlowID: "flow-3", + State: FlowStatePending, + }) + + sentinel := "verified-code" + s.Update("flow-3", FlowStateAuthorized, func(fs *FlowStatus) { + fs.UserCode = sentinel + }) + + got, ok := s.Get("flow-3") + if !ok { + t.Fatal("Get: expected flow to exist") + } + if got.State != FlowStateAuthorized { + t.Errorf("State = %q, want %q", got.State, FlowStateAuthorized) + } + if got.UserCode != sentinel { + t.Errorf("UserCode = %q, want %q", got.UserCode, sentinel) + } +} + +func TestFlowStore_UpdateMissingIsNoOp(t *testing.T) { + s := NewFlowStore() + // Should not panic. + s.Update("no-such-flow", FlowStateFailed, nil) +} + +func TestFlowStore_Delete(t *testing.T) { + s := NewFlowStore() + s.Create(FlowStatus{FlowID: "flow-del", State: FlowStatePending}) + + s.Delete("flow-del") + + _, ok := s.Get("flow-del") + if ok { + t.Fatal("Get after Delete: expected flow to be absent") + } +} + +func TestFlowStore_DeleteMissingIsNoOp(t *testing.T) { + s := NewFlowStore() + // Should not panic. + s.Delete("not-there") +} diff --git a/internal/oauthcli/vault_test.go b/internal/oauthcli/vault_test.go new file mode 100644 index 00000000..e5767e16 --- /dev/null +++ b/internal/oauthcli/vault_test.go @@ -0,0 +1,182 @@ +package oauthcli + +import ( + "context" + "fmt" + "testing" + "time" +) + +// mockVaultStore is a simple in-memory VaultStore for testing. +type mockVaultStore struct { + data map[string]string // keyed by "userID:name" +} + +func newMockVaultStore() *mockVaultStore { + return &mockVaultStore{data: make(map[string]string)} +} + +func (m *mockVaultStore) key(userID int64, name string) string { + return fmt.Sprintf("%d:%s", userID, name) +} + +func (m *mockVaultStore) Set(_ context.Context, userID int64, name string, plaintext string) error { + m.data[m.key(userID, name)] = plaintext + return nil +} + +func (m *mockVaultStore) Delete(_ context.Context, userID int64, name string) error { + delete(m.data, m.key(userID, name)) + return nil +} + +func (m *mockVaultStore) LoadEnv(_ context.Context, userID int64) (map[string]string, error) { + out := make(map[string]string) + prefix := fmt.Sprintf("%d:", userID) + for k, v := range m.data { + if len(k) > len(prefix) && k[:len(prefix)] == prefix { + name := k[len(prefix):] + out[name] = v + } + } + return out, nil +} + +func TestSaveLoadGHBundle_RoundTrip(t *testing.T) { + vs := newMockVaultStore() + ctx := context.Background() + userID := int64(1) + + expiry := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + bundle := GHOAuthBundle{ + Version: 1, + AccessToken: "ghp_test_token", + TokenType: "bearer", + Scope: "repo,read:org", + ExpiresAt: &expiry, + } + + if err := SaveGHBundle(ctx, vs, userID, bundle); err != nil { + t.Fatalf("SaveGHBundle: %v", err) + } + + got, err := LoadGHBundle(ctx, vs, userID) + if err != nil { + t.Fatalf("LoadGHBundle: %v", err) + } + if got == nil { + t.Fatal("LoadGHBundle: expected non-nil bundle") + } + if got.AccessToken != bundle.AccessToken { + t.Errorf("AccessToken = %q, want %q", got.AccessToken, bundle.AccessToken) + } + if got.TokenType != bundle.TokenType { + t.Errorf("TokenType = %q, want %q", got.TokenType, bundle.TokenType) + } + if got.Scope != bundle.Scope { + t.Errorf("Scope = %q, want %q", got.Scope, bundle.Scope) + } + if got.Version != bundle.Version { + t.Errorf("Version = %d, want %d", got.Version, bundle.Version) + } + if got.ExpiresAt == nil || !got.ExpiresAt.Equal(*bundle.ExpiresAt) { + t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, bundle.ExpiresAt) + } +} + +func TestLoadGHBundle_AbsentKeyReturnsNil(t *testing.T) { + vs := newMockVaultStore() + ctx := context.Background() + + got, err := LoadGHBundle(ctx, vs, 42) + if err != nil { + t.Fatalf("LoadGHBundle: unexpected error: %v", err) + } + if got != nil { + t.Errorf("LoadGHBundle: expected nil for absent key, got %+v", got) + } +} + +func TestSaveLoadLarkBundle_RoundTrip(t *testing.T) { + vs := newMockVaultStore() + ctx := context.Background() + userID := int64(2) + + now := time.Now().UTC().Truncate(time.Second) + bundle := LarkOAuthBundle{ + Version: 1, + AppID: "cli_test_app_id", + AppSecret: "cli_test_app_secret", + Brand: "lark", + AccessToken: "u-test-access-token", + RefreshToken: "u-test-refresh-token", + AccessExpiresAt: now.Add(2 * time.Hour), + RefreshExpiresAt: now.Add(30 * 24 * time.Hour), + } + + if err := SaveLarkBundle(ctx, vs, userID, bundle); err != nil { + t.Fatalf("SaveLarkBundle: %v", err) + } + + got, err := LoadLarkBundle(ctx, vs, userID) + if err != nil { + t.Fatalf("LoadLarkBundle: %v", err) + } + if got == nil { + t.Fatal("LoadLarkBundle: expected non-nil bundle") + } + if got.AppID != bundle.AppID { + t.Errorf("AppID = %q, want %q", got.AppID, bundle.AppID) + } + if got.AppSecret != bundle.AppSecret { + t.Errorf("AppSecret = %q, want %q", got.AppSecret, bundle.AppSecret) + } + if got.Brand != bundle.Brand { + t.Errorf("Brand = %q, want %q", got.Brand, bundle.Brand) + } + if got.AccessToken != bundle.AccessToken { + t.Errorf("AccessToken = %q, want %q", got.AccessToken, bundle.AccessToken) + } + if got.RefreshToken != bundle.RefreshToken { + t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, bundle.RefreshToken) + } + if !got.AccessExpiresAt.Equal(bundle.AccessExpiresAt) { + t.Errorf("AccessExpiresAt = %v, want %v", got.AccessExpiresAt, bundle.AccessExpiresAt) + } +} + +func TestLoadLarkBundle_AbsentKeyReturnsNil(t *testing.T) { + vs := newMockVaultStore() + ctx := context.Background() + + got, err := LoadLarkBundle(ctx, vs, 99) + if err != nil { + t.Fatalf("LoadLarkBundle: unexpected error: %v", err) + } + if got != nil { + t.Errorf("LoadLarkBundle: expected nil for absent key, got %+v", got) + } +} + +func TestDeleteBundle(t *testing.T) { + vs := newMockVaultStore() + ctx := context.Background() + userID := int64(3) + + bundle := GHOAuthBundle{Version: 1, AccessToken: "ghp_todelete"} + if err := SaveGHBundle(ctx, vs, userID, bundle); err != nil { + t.Fatalf("SaveGHBundle: %v", err) + } + + if err := DeleteBundle(ctx, vs, userID, VaultKeyGitHub); err != nil { + t.Fatalf("DeleteBundle: %v", err) + } + + got, err := LoadGHBundle(ctx, vs, userID) + if err != nil { + t.Fatalf("LoadGHBundle after delete: %v", err) + } + if got != nil { + t.Errorf("LoadGHBundle after delete: expected nil, got %+v", got) + } +} diff --git a/internal/resources/skills/system/anna/SKILL.md b/internal/resources/skills/system/anna/SKILL.md index 582d0fae..795570fa 100644 --- a/internal/resources/skills/system/anna/SKILL.md +++ b/internal/resources/skills/system/anna/SKILL.md @@ -122,5 +122,6 @@ These are tools you already have access to. Briefly: - **Notifications**: `notify` tool (gateway mode only) -- send messages via Telegram/QQ/Feishu/WeChat dispatcher. - **Session compaction**: auto-triggers at 80k tokens, or manually via `/compact`. Configurable in settings. - **Managed helper CLIs**: The `bash` tool prepends Anna-managed binaries to `PATH`. Expect `fd`, `rg`, `mise`, and `tap` to be available even when the host machine doesn't have them installed separately. +- **CLI OAuth (`gh` and `lark-cli`)**: When the user has connected their GitHub or Lark account via Profile → OAuth CLI Credentials, `gh` and `lark-cli` work directly in `bash` tool calls without any manual auth step. Anna injects a fresh runtime token at session start. Note: Lark user access tokens expire after ~2 hours; start a new session to refresh. If the user has not connected, `gh` and `lark-cli` are still on `PATH` but will require manual authentication. - **Plugins**: Anna now uses a unified plugin host. A plugin owns its config, runtime lifecycle, status, and capability registrations. Built-in capabilities currently cover tools (`mcp`, `webfetch`), channels (telegram, qq, feishu, weixin), hooks (trace, rtk), providers (anthropic, openai, openai-response), memory (`lcm`, `simple`), and the standalone reflect runtime. Core tools (read/bash/edit/write/agent/memory/scheduler/skills) are always enabled and are not plugins. The `mcp` plugin lets admins configure multiple MCP servers/transports in the admin UI, reconciles its runtime through the plugin host, exposes one generic `mcp` tool, and contributes structured prompt inventory for discovered MCP tool IDs. The `reflect` plugin also reconciles its background review loop through the host while keeping the existing `reflect` settings row. The `telegram`, `qq`, `feishu`, and `weixin` channels all use the same host-backed config/runtime/status path while keeping their existing `channel/...` rows and `/channels` admin UX. When using MCP tools, inspect prompt-listed MCP tool IDs and always call `mcp` with `action="get"` before `action="exec"`. Manage plugins with `anna plugin list/enable/disable/config`. - **Trace hook**: The `trace` hook logs all LLM calls, tool executions, and memory operations via slog. Set `OTEL_EXPORTER_OTLP_ENDPOINT` to also export OpenTelemetry traces using standard OTel env vars. Both OTLP/gRPC and OTLP/HTTP are supported, including auth headers via `OTEL_EXPORTER_OTLP_HEADERS` or `OTEL_EXPORTER_OTLP_TRACES_HEADERS`. Always include a scheme in the endpoint (for example `http://localhost:4317` or `https://collector.example.com/api/default`). No code changes needed -- just set the env vars and restart. diff --git a/plugins/sandbox/docker/session_pure_test.go b/plugins/sandbox/docker/session_pure_test.go index 39011957..cf096a8d 100644 --- a/plugins/sandbox/docker/session_pure_test.go +++ b/plugins/sandbox/docker/session_pure_test.go @@ -63,3 +63,80 @@ func TestMapNetworkMode(t *testing.T) { } } } + +func TestInjectWrapperPath_PrependedWhenSet(t *testing.T) { + env := map[string]string{ + "ANNA_WRAPPER_DIR": "/home/anna/workspace/.anna/bin", + "PATH": "/usr/bin:/bin", + } + got := injectWrapperPath(env) + want := "/home/anna/workspace/.anna/bin:/usr/bin:/bin" + if got["PATH"] != want { + t.Errorf("PATH = %q, want %q", got["PATH"], want) + } +} + +func TestInjectWrapperPath_UsesDefaultPathWhenPATHAbsent(t *testing.T) { + env := map[string]string{ + "ANNA_WRAPPER_DIR": "/home/anna/workspace/.anna/bin", + } + got := injectWrapperPath(env) + if got["PATH"] == "" { + t.Fatal("PATH should not be empty when ANNA_WRAPPER_DIR is set") + } + if got["PATH"][:len("/home/anna/workspace/.anna/bin:")] != "/home/anna/workspace/.anna/bin:" { + t.Errorf("PATH does not start with wrapper dir: %q", got["PATH"]) + } + // Should include the container default PATH. + if len(got["PATH"]) <= len("/home/anna/workspace/.anna/bin:") { + t.Error("PATH should include containerDefaultPATH after wrapper dir") + } +} + +func TestInjectWrapperPath_NoOpWhenAbsent(t *testing.T) { + env := map[string]string{ + "PATH": "/usr/bin:/bin", + } + got := injectWrapperPath(env) + if got["PATH"] != "/usr/bin:/bin" { + t.Errorf("PATH changed when ANNA_WRAPPER_DIR absent: %q", got["PATH"]) + } +} + +func TestInjectWrapperPath_NoOpWhenEmpty(t *testing.T) { + env := map[string]string{ + "ANNA_WRAPPER_DIR": "", + "PATH": "/usr/bin:/bin", + } + got := injectWrapperPath(env) + if got["PATH"] != "/usr/bin:/bin" { + t.Errorf("PATH changed when ANNA_WRAPPER_DIR is empty: %q", got["PATH"]) + } +} + +func TestInjectDockerBinPaths_SetsFixedPaths(t *testing.T) { + env := map[string]string{} + got := injectDockerBinPaths(env) + + if got["ANNA_GH_BIN"] != "/usr/bin/gh" { + t.Errorf("ANNA_GH_BIN = %q, want %q", got["ANNA_GH_BIN"], "/usr/bin/gh") + } + if got["ANNA_LARK_BIN"] != "/usr/local/bin/lark-cli" { + t.Errorf("ANNA_LARK_BIN = %q, want %q", got["ANNA_LARK_BIN"], "/usr/local/bin/lark-cli") + } +} + +func TestInjectDockerBinPaths_OverridesHostPaths(t *testing.T) { + env := map[string]string{ + "ANNA_GH_BIN": "/host/anna/bin/gh", + "ANNA_LARK_BIN": "/host/anna/bin/lark-cli", + } + got := injectDockerBinPaths(env) + + if got["ANNA_GH_BIN"] != "/usr/bin/gh" { + t.Errorf("ANNA_GH_BIN = %q, want container path %q", got["ANNA_GH_BIN"], "/usr/bin/gh") + } + if got["ANNA_LARK_BIN"] != "/usr/local/bin/lark-cli" { + t.Errorf("ANNA_LARK_BIN = %q, want container path %q", got["ANNA_LARK_BIN"], "/usr/local/bin/lark-cli") + } +} From 001768935181756d61ff256e7d288c2027407f9c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 08:54:03 +0800 Subject: [PATCH 07/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20remove?= =?UTF-8?q?=20hardcoded=20gh=20and=20lark-cli=20from=20dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move gh and lark-cli installation from Dockerfile to mise-managed tools in _mise.toml for better version flexibility and consistency. --- plugins/sandbox/docker/Dockerfile | 27 --------------------------- plugins/sandbox/docker/_mise.toml | 2 ++ 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/plugins/sandbox/docker/Dockerfile b/plugins/sandbox/docker/Dockerfile index f75a9e10..bfabae42 100644 --- a/plugins/sandbox/docker/Dockerfile +++ b/plugins/sandbox/docker/Dockerfile @@ -24,33 +24,6 @@ RUN apt-get update && \ ln -s /usr/bin/fdfind /usr/local/bin/fd && \ apt-get clean && rm -rf /var/lib/apt/lists/* -# GitHub CLI (gh) — version pinned to match internal/builddeps/binaries.go ghCLIVersion -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ - apt-get update && \ - apt-get install -y gh=2.89.0 && \ - apt-get clean && rm -rf /var/lib/apt/lists/* - -# lark-cli — version pinned to match internal/builddeps/binaries.go larkCLIVersion -# Released as a tarball from github.com/larksuite/cli at /usr/local/bin/lark-cli -# (injectDockerBinPaths in plugins/sandbox/docker/session.go relies on this path) -ARG LARK_CLI_VERSION=1.0.15 -RUN set -eux; \ - ARCH="$(dpkg --print-architecture)"; \ - case "$ARCH" in \ - amd64) LARK_ARCH="linux-amd64" ;; \ - arm64) LARK_ARCH="linux-arm64" ;; \ - *) echo "unsupported architecture: $ARCH" >&2; exit 1 ;; \ - esac; \ - curl -fsSL \ - "https://github.com/larksuite/cli/releases/download/v${LARK_CLI_VERSION}/lark-cli-${LARK_CLI_VERSION}-${LARK_ARCH}.tar.gz" \ - -o /tmp/lark-cli.tar.gz && \ - tar -xzf /tmp/lark-cli.tar.gz -C /tmp && \ - find /tmp -name "lark-cli" -type f -exec install -m 755 {} /usr/local/bin/lark-cli \; && \ - rm -f /tmp/lark-cli.tar.gz - ARG USER_UID=1000 ARG USER_GID=1000 diff --git a/plugins/sandbox/docker/_mise.toml b/plugins/sandbox/docker/_mise.toml index 5fb082d9..90c0fa30 100644 --- a/plugins/sandbox/docker/_mise.toml +++ b/plugins/sandbox/docker/_mise.toml @@ -10,6 +10,8 @@ LIBUNWIND_PRINT_DWARF = "0" UV_PYTHON = "/usr/bin/python3" [tools] +gh = "latest" "github:vaayne/tap" = "latest" "github:rtk-ai/rtk" = "latest" "github:xicilion/boxsh" = "latest" +"github:larksuite/cli" = "latest" From e11c4b627816a000b839f927710ce06f6a764357 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 08:54:08 +0800 Subject: [PATCH 08/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(oauthcli):?= =?UTF-8?q?=20use=20golang.org/x/oauth2=20for=20GitHub=20device=20flow=20a?= =?UTF-8?q?nd=20Lark=20auth=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled HTTP for GitHub device flow with oauth2.Config + github.Endpoint. StartDeviceFlow spawns a background goroutine running cfg.DeviceAccessToken; Poll reads FlowStore state without making HTTP calls. Lark auth URL now built via oauth2.Config.AuthCodeURL; token exchange and refresh stay as custom HTTP (Lark requires Bearer app_access_token header). Extract postJSON helper to http.go. Assisted-by: claude-code:claude-sonnet-4-6 --- internal/oauthcli/gh.go | 258 +++++++++++--------------------------- internal/oauthcli/http.go | 34 +++++ internal/oauthcli/lark.go | 44 ++++--- 3 files changed, 127 insertions(+), 209 deletions(-) create mode 100644 internal/oauthcli/http.go diff --git a/internal/oauthcli/gh.go b/internal/oauthcli/gh.go index 1de2f84e..417d3af3 100644 --- a/internal/oauthcli/gh.go +++ b/internal/oauthcli/gh.go @@ -1,24 +1,17 @@ package oauthcli import ( - "bytes" "context" - "encoding/json" "fmt" - "net/http" - "net/url" - "strings" "sync" "time" "github.com/google/uuid" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" ) -const ( - ghDeviceCodeURL = "https://github.com/login/device/code" - ghAccessTokenURL = "https://github.com/login/oauth/access_token" - ghScope = "repo,read:org" -) +const ghScope = "repo,read:org" // GitHubConfig holds the OAuth app credentials for device flow. type GitHubConfig struct { @@ -26,11 +19,12 @@ type GitHubConfig struct { ClientSecret string } -// ghFlowSecret holds provider-specific secrets for an in-flight GitHub flow. +// ghFlowSecret holds in-flight state for a GitHub device flow. type ghFlowSecret struct { - deviceCode string - interval int // seconds between polls, as returned by GitHub - bundle *GHOAuthBundle + cancel context.CancelFunc + token *oauth2.Token + err error + done chan struct{} } // GitHubBroker manages GitHub device-flow sessions. @@ -50,87 +44,71 @@ func NewGitHubBroker(cfg GitHubConfig, store *FlowStore) *GitHubBroker { } } -// ghDeviceCodeResponse is the JSON body from POST /login/device/code. -type ghDeviceCodeResponse struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` -} - -// ghAccessTokenResponse is the JSON body from POST /login/oauth/access_token. -type ghAccessTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` - Error string `json:"error"` - // Fine-grained tokens may carry expiry. - ExpiresIn int `json:"expires_in,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` +func (b *GitHubBroker) oauthConfig() *oauth2.Config { + return &oauth2.Config{ + ClientID: b.cfg.ClientID, + ClientSecret: b.cfg.ClientSecret, + Scopes: []string{ghScope}, + Endpoint: github.Endpoint, + } } // StartDeviceFlow requests a device code from GitHub, stores pending state, // and returns the FlowStatus the caller should display to the user. +// A background goroutine polls GitHub until the user authorizes or the flow expires. func (b *GitHubBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowStatus, error) { - body := url.Values{} - body.Set("client_id", b.cfg.ClientID) - body.Set("scope", ghScope) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghDeviceCodeURL, - strings.NewReader(body.Encode())) - if err != nil { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: build device code request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") + cfg := b.oauthConfig() - resp, err := http.DefaultClient.Do(req) + da, err := cfg.DeviceAuth(ctx, oauth2.AccessTypeOnline) if err != nil { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: device code request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: device code: unexpected status %d", resp.StatusCode) - } - - var dc ghDeviceCodeResponse - if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: decode device code response: %w", err) - } - - if dc.Interval <= 0 { - dc.Interval = 5 // GitHub default + return FlowStatus{}, fmt.Errorf("oauthcli/gh: device auth: %w", err) } flowID := uuid.NewString() - expiresAt := time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second) + expiresAt := da.Expiry status := FlowStatus{ Provider: ProviderGitHub, FlowID: flowID, - VerificationURI: dc.VerificationURI, - UserCode: dc.UserCode, + VerificationURI: da.VerificationURIComplete, + UserCode: da.UserCode, ExpiresAt: expiresAt, State: FlowStatePending, } + if status.VerificationURI == "" { + status.VerificationURI = da.VerificationURI + } b.store.Create(status) - b.mu.Lock() - b.secret[flowID] = &ghFlowSecret{ - deviceCode: dc.DeviceCode, - interval: dc.Interval, + bgCtx, cancel := context.WithDeadline(context.Background(), expiresAt) + sec := &ghFlowSecret{ + cancel: cancel, + done: make(chan struct{}), } + b.mu.Lock() + b.secret[flowID] = sec b.mu.Unlock() + go func() { + defer close(sec.done) + tok, err := cfg.DeviceAccessToken(bgCtx, da) + b.mu.Lock() + sec.token = tok + sec.err = err + b.mu.Unlock() + if err == nil { + b.store.Update(flowID, FlowStateAuthorized, nil) + } else { + b.store.Update(flowID, FlowStateFailed, nil) + } + }() + return status, nil } // Poll checks whether the user has completed authorization for flowID. -// It updates the store state and returns the current FlowStatus. -// Callers must call Complete after Poll returns State == FlowStateAuthorized. +// It reads from the store — the background goroutine updates it when done. func (b *GitHubBroker) Poll(ctx context.Context, flowID string) (FlowStatus, error) { status, ok := b.store.Get(flowID) if !ok { @@ -148,103 +126,44 @@ func (b *GitHubBroker) Poll(ctx context.Context, flowID string) (FlowStatus, err return status, nil } + return status, nil +} + +// Complete persists the token bundle to vault. Must be called only after Poll +// returns State == FlowStateAuthorized. +func (b *GitHubBroker) Complete(ctx context.Context, vs VaultStore, userID int64, flowID string) error { b.mu.Lock() sec, ok := b.secret[flowID] b.mu.Unlock() if !ok { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: missing secrets for flow %q", flowID) + return fmt.Errorf("oauthcli/gh: no authorized token for flow %q", flowID) } - reqBody := url.Values{} - reqBody.Set("client_id", b.cfg.ClientID) - reqBody.Set("device_code", sec.deviceCode) - reqBody.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + // Wait for the background goroutine to finish if it hasn't yet. + <-sec.done - req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghAccessTokenURL, - strings.NewReader(reqBody.Encode())) - if err != nil { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: build poll request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") + b.mu.Lock() + tok := sec.token + err := sec.err + b.mu.Unlock() - resp, err := http.DefaultClient.Do(req) if err != nil { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: poll request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - var at ghAccessTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&at); err != nil { - return FlowStatus{}, fmt.Errorf("oauthcli/gh: decode poll response: %w", err) + return fmt.Errorf("oauthcli/gh: flow %q failed: %w", flowID, err) } - - switch at.Error { - case "": - // Success — stash the token bundle, mark authorized. - bundle := &GHOAuthBundle{ - Version: 1, - AccessToken: at.AccessToken, - TokenType: at.TokenType, - Scope: at.Scope, - RefreshToken: at.RefreshToken, - } - if at.ExpiresIn > 0 { - t := time.Now().Add(time.Duration(at.ExpiresIn) * time.Second) - bundle.ExpiresAt = &t - } - b.mu.Lock() - sec.bundle = bundle - b.mu.Unlock() - b.store.Update(flowID, FlowStateAuthorized, nil) - status.State = FlowStateAuthorized - return status, nil - - case "authorization_pending": - // Still waiting — no change. - return status, nil - - case "slow_down": - // GitHub wants us to back off; bump the stored interval and keep waiting. - b.mu.Lock() - sec.interval += 5 - b.mu.Unlock() - return status, nil - - case "expired_token": - b.store.Update(flowID, FlowStateExpired, nil) - b.cleanSecret(flowID) - status.State = FlowStateExpired - return status, nil - - case "access_denied": - b.store.Update(flowID, FlowStateFailed, nil) - b.cleanSecret(flowID) - status.State = FlowStateFailed - return status, fmt.Errorf("oauthcli/gh: access denied by user") - - default: - b.store.Update(flowID, FlowStateFailed, nil) - b.cleanSecret(flowID) - status.State = FlowStateFailed - return status, fmt.Errorf("oauthcli/gh: unexpected error %q from GitHub", at.Error) + if tok == nil { + return fmt.Errorf("oauthcli/gh: flow %q is not yet authorized", flowID) } -} -// Complete persists the token bundle to vault. Must be called only after Poll -// returns State == FlowStateAuthorized. -func (b *GitHubBroker) Complete(ctx context.Context, vs VaultStore, userID int64, flowID string) error { - b.mu.Lock() - sec, ok := b.secret[flowID] - b.mu.Unlock() - if !ok { - return fmt.Errorf("oauthcli/gh: no authorized token for flow %q", flowID) + bundle := GHOAuthBundle{ + Version: 1, + AccessToken: tok.AccessToken, + TokenType: tok.TokenType, } - if sec.bundle == nil { - return fmt.Errorf("oauthcli/gh: flow %q is not yet authorized", flowID) + if !tok.Expiry.IsZero() { + bundle.ExpiresAt = &tok.Expiry } - if err := SaveGHBundle(ctx, vs, userID, *sec.bundle); err != nil { + if err := SaveGHBundle(ctx, vs, userID, bundle); err != nil { return err } @@ -253,44 +172,11 @@ func (b *GitHubBroker) Complete(ctx context.Context, vs VaultStore, userID int64 return nil } -// PollInterval returns the current recommended seconds-between-polls for a -// flow. Returns 5 (GitHub default) if the flow is unknown. -func (b *GitHubBroker) PollInterval(flowID string) int { +func (b *GitHubBroker) cleanSecret(flowID string) { b.mu.Lock() - defer b.mu.Unlock() if sec, ok := b.secret[flowID]; ok { - return sec.interval + sec.cancel() + delete(b.secret, flowID) } - return 5 -} - -func (b *GitHubBroker) cleanSecret(flowID string) { - b.mu.Lock() - delete(b.secret, flowID) b.mu.Unlock() } - -// postJSON is a small helper to POST a JSON body and decode the response. -func postJSON(ctx context.Context, url string, reqBody any, headers map[string]string, out any) error { - data, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("build request: %w", err) - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - for k, v := range headers { - req.Header.Set(k, v) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("do request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("unexpected status %d", resp.StatusCode) - } - return json.NewDecoder(resp.Body).Decode(out) -} diff --git a/internal/oauthcli/http.go b/internal/oauthcli/http.go new file mode 100644 index 00000000..3e653608 --- /dev/null +++ b/internal/oauthcli/http.go @@ -0,0 +1,34 @@ +package oauthcli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// postJSON POSTs a JSON body and decodes the JSON response into out. +func postJSON(ctx context.Context, url string, reqBody any, headers map[string]string, out any) error { + data, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go index d2c25d33..da573c57 100644 --- a/internal/oauthcli/lark.go +++ b/internal/oauthcli/lark.go @@ -3,11 +3,11 @@ package oauthcli import ( "context" "fmt" - "net/url" "sync" "time" "github.com/google/uuid" + "golang.org/x/oauth2" ) const ( @@ -15,6 +15,7 @@ const ( larkBaseLark = "https://open.larksuite.com" larkRedirectURI = "https://anna.app/oauth/lark/callback" // placeholder; caller sets up the real redirect + larkScope = "contact:user.base:readonly" ) // LarkConfig holds the OAuth app credentials for Lark/Feishu device-style flow. @@ -71,16 +72,26 @@ func (b *LarkBroker) baseURL() string { return larkBaseLark } +func (b *LarkBroker) oauthConfig() *oauth2.Config { + base := b.baseURL() + return &oauth2.Config{ + ClientID: b.cfg.AppID, + RedirectURL: b.redirectURI, + Scopes: []string{larkScope}, + Endpoint: oauth2.Endpoint{ + AuthURL: base + "/open-apis/authen/v1/authorize", + TokenURL: base + "/open-apis/authen/v1/oidc/access_token", + }, + } +} + // StartDeviceFlow generates a state token, constructs the authorization URL, // and stores a pending FlowStatus. The user must navigate to VerificationURI. func (b *LarkBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowStatus, error) { flowID := uuid.NewString() - expiresAt := time.Now().Add(10 * time.Minute) // Lark code flows typically timeout within minutes + expiresAt := time.Now().Add(10 * time.Minute) - authURL, err := b.buildAuthURL(flowID) - if err != nil { - return FlowStatus{}, fmt.Errorf("oauthcli/lark: build auth url: %w", err) - } + authURL := b.oauthConfig().AuthCodeURL(flowID) status := FlowStatus{ Provider: ProviderLark, @@ -117,7 +128,6 @@ func (b *LarkBroker) Poll(ctx context.Context, flowID string) (FlowStatus, error return status, nil } - // Check if Complete was called and stashed a bundle. b.mu.Lock() sec, ok := b.secret[flowID] b.mu.Unlock() @@ -156,7 +166,6 @@ func (b *LarkBroker) Complete(ctx context.Context, vs VaultStore, userID int64, return err } - // Update store so polling callers see the completion immediately. b.store.Update(flowID, FlowStateAuthorized, nil) b.mu.Lock() if sec, ok := b.secret[flowID]; ok { @@ -178,6 +187,8 @@ type larkAppTokenResponse struct { } // fetchAppAccessToken obtains a short-lived Lark app access token. +// Lark requires a separate app credential exchange before user token operations — +// the standard oauth2 flow does not support this pattern. func (b *LarkBroker) fetchAppAccessToken(ctx context.Context) (string, error) { endpoint := b.baseURL() + "/open-apis/auth/v3/app_access_token/internal" reqBody := map[string]string{ @@ -208,6 +219,8 @@ type larkUserTokenResponse struct { } // exchangeCode exchanges an authorization code for a user access token. +// Uses custom HTTP because Lark requires Authorization: Bearer , +// which the standard oauth2.Config.Exchange() does not support. func (b *LarkBroker) exchangeCode(ctx context.Context, appToken string, code string) (*LarkOAuthBundle, error) { endpoint := b.baseURL() + "/open-apis/authen/v1/oidc/access_token" reqBody := map[string]string{ @@ -235,21 +248,6 @@ func (b *LarkBroker) exchangeCode(ctx context.Context, appToken string, code str return bundle, nil } -func (b *LarkBroker) buildAuthURL(state string) (string, error) { - base := b.baseURL() + "/open-apis/authen/v1/authorize" - params := url.Values{} - params.Set("app_id", b.cfg.AppID) - params.Set("redirect_uri", b.redirectURI) - params.Set("state", state) - params.Set("scope", "contact:user.base:readonly") - u, err := url.Parse(base) - if err != nil { - return "", err - } - u.RawQuery = params.Encode() - return u.String(), nil -} - func (b *LarkBroker) cleanSecret(flowID string) { b.mu.Lock() delete(b.secret, flowID) From 69f88aa1222e93db356bd506834ccf837b283a2c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 08:59:48 +0800 Subject: [PATCH 09/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(oauthcli):?= =?UTF-8?q?=20simplify=20vault=20generics,=20remove=20dead=20lark=20secret?= =?UTF-8?q?=20map,=20drop=20unused=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vault.go: replace duplicate marshal/unmarshal logic with generic saveBundle/loadBundle helpers - lark.go: remove larkFlowSecret struct and secret map (bundle field was unreachable in Poll) - token_manager.go: drop unused appID/brand params from GetLarkRuntimeEnv - sandbox_backend.go: update call site to match new signature Assisted-by: Claude Code:claude-sonnet-4-6 --- internal/agent/runner/sandbox_backend.go | 2 +- internal/oauthcli/lark.go | 72 +++--------------------- internal/oauthcli/token_manager.go | 11 ++-- internal/oauthcli/vault.go | 55 ++++++++---------- 4 files changed, 35 insertions(+), 105 deletions(-) diff --git a/internal/agent/runner/sandbox_backend.go b/internal/agent/runner/sandbox_backend.go index fe1f965c..3fa5d8f2 100644 --- a/internal/agent/runner/sandbox_backend.go +++ b/internal/agent/runner/sandbox_backend.go @@ -160,7 +160,7 @@ func buildSandboxEnv(ctx context.Context, cfg GoRunnerConfig, paths sandboxPaths ) } - if larkEnv, err := cfg.TokenManager.GetLarkRuntimeEnv(ctx, cfg.UserID, cfg.LarkAppID, cfg.LarkBrand); err == nil { + if larkEnv, err := cfg.TokenManager.GetLarkRuntimeEnv(ctx, cfg.UserID); err == nil { maps.Copy(env, larkEnv) } else { slog.Debug("lark env injection skipped", diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go index da573c57..77eab5e1 100644 --- a/internal/oauthcli/lark.go +++ b/internal/oauthcli/lark.go @@ -3,7 +3,6 @@ package oauthcli import ( "context" "fmt" - "sync" "time" "github.com/google/uuid" @@ -25,11 +24,6 @@ type LarkConfig struct { Brand string // "lark" or "feishu" } -// larkFlowSecret holds provider-specific state for an in-flight Lark flow. -type larkFlowSecret struct { - bundle *LarkOAuthBundle -} - // LarkBroker manages Lark/Feishu OAuth sessions via the authorization-code // flow adapted for device-like use: StartDeviceFlow returns a URL the user // visits; Poll checks completion; Complete exchanges the code and saves the @@ -38,31 +32,18 @@ type LarkBroker struct { cfg LarkConfig store *FlowStore redirectURI string - mu sync.Mutex - secret map[string]*larkFlowSecret } // NewLarkBroker constructs a LarkBroker. redirectURI is the OAuth callback URL // that your HTTP handler will receive and then call Complete on. func NewLarkBroker(cfg LarkConfig, store *FlowStore) *LarkBroker { - return &LarkBroker{ - cfg: cfg, - store: store, - redirectURI: larkRedirectURI, - secret: make(map[string]*larkFlowSecret), - } + return &LarkBroker{cfg: cfg, store: store, redirectURI: larkRedirectURI} } // WithRedirectURI returns a new broker with the same configuration but the -// given redirect URI. The new broker has its own independent mutex and secret -// map, so it is safe to use concurrently with the original. +// given redirect URI. func (b *LarkBroker) WithRedirectURI(uri string) *LarkBroker { - return &LarkBroker{ - cfg: b.cfg, - store: b.store, - redirectURI: uri, - secret: make(map[string]*larkFlowSecret), - } + return &LarkBroker{cfg: b.cfg, store: b.store, redirectURI: uri} } func (b *LarkBroker) baseURL() string { @@ -89,23 +70,14 @@ func (b *LarkBroker) oauthConfig() *oauth2.Config { // and stores a pending FlowStatus. The user must navigate to VerificationURI. func (b *LarkBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowStatus, error) { flowID := uuid.NewString() - expiresAt := time.Now().Add(10 * time.Minute) - - authURL := b.oauthConfig().AuthCodeURL(flowID) - status := FlowStatus{ Provider: ProviderLark, FlowID: flowID, - VerificationURI: authURL, - ExpiresAt: expiresAt, + VerificationURI: b.oauthConfig().AuthCodeURL(flowID), + ExpiresAt: time.Now().Add(10 * time.Minute), State: FlowStatePending, } - b.store.Create(status) - b.mu.Lock() - b.secret[flowID] = &larkFlowSecret{} - b.mu.Unlock() - return status, nil } @@ -116,26 +88,13 @@ func (b *LarkBroker) Poll(ctx context.Context, flowID string) (FlowStatus, error if !ok { return FlowStatus{}, fmt.Errorf("oauthcli/lark: unknown flow %q", flowID) } - if status.State != FlowStatePending { return status, nil } - if time.Now().After(status.ExpiresAt) { b.store.Update(flowID, FlowStateExpired, nil) - b.cleanSecret(flowID) status.State = FlowStateExpired - return status, nil } - - b.mu.Lock() - sec, ok := b.secret[flowID] - b.mu.Unlock() - if ok && sec.bundle != nil { - status.State = FlowStateAuthorized - return status, nil - } - return status, nil } @@ -143,8 +102,7 @@ func (b *LarkBroker) Poll(ctx context.Context, flowID string) (FlowStatus, error // vault, and marks the flow as authorized. The code comes from your OAuth // callback handler's query parameter. func (b *LarkBroker) Complete(ctx context.Context, vs VaultStore, userID int64, flowID string, code string) error { - _, ok := b.store.Get(flowID) - if !ok { + if _, ok := b.store.Get(flowID); !ok { return fmt.Errorf("oauthcli/lark: unknown flow %q", flowID) } @@ -167,14 +125,7 @@ func (b *LarkBroker) Complete(ctx context.Context, vs VaultStore, userID int64, } b.store.Update(flowID, FlowStateAuthorized, nil) - b.mu.Lock() - if sec, ok := b.secret[flowID]; ok { - sec.bundle = bundle - } - b.mu.Unlock() - b.store.Delete(flowID) - b.cleanSecret(flowID) return nil } @@ -239,17 +190,10 @@ func (b *LarkBroker) exchangeCode(ctx context.Context, appToken string, code str } now := time.Now() - bundle := &LarkOAuthBundle{ + return &LarkOAuthBundle{ AccessToken: resp.Data.AccessToken, RefreshToken: resp.Data.RefreshToken, AccessExpiresAt: now.Add(time.Duration(resp.Data.ExpiresIn) * time.Second), RefreshExpiresAt: now.Add(time.Duration(resp.Data.RefreshExpiresIn) * time.Second), - } - return bundle, nil -} - -func (b *LarkBroker) cleanSecret(flowID string) { - b.mu.Lock() - delete(b.secret, flowID) - b.mu.Unlock() + }, nil } diff --git a/internal/oauthcli/token_manager.go b/internal/oauthcli/token_manager.go index bf9a3c54..6c447959 100644 --- a/internal/oauthcli/token_manager.go +++ b/internal/oauthcli/token_manager.go @@ -39,13 +39,10 @@ func (m *TokenManager) GetGHToken(ctx context.Context, userID int64) (string, er } // GetLarkRuntimeEnv returns the environment variables needed for lark-cli. -// It loads the Lark bundle, refreshes the access token if expired (using the -// bundle's stored AppSecret), and returns a map ready for env injection. -// -// If the bundle is absent or fully expired (refresh token expired), it returns -// a descriptive error so the runner can skip Lark env injection without failing -// the entire session. -func (m *TokenManager) GetLarkRuntimeEnv(ctx context.Context, userID int64, appID, brand string) (map[string]string, error) { +// It loads the Lark bundle, refreshes the access token if expired, and returns +// a map ready for env injection. Returns an error if the bundle is absent or +// the refresh token has expired, so callers can skip injection without failing. +func (m *TokenManager) GetLarkRuntimeEnv(ctx context.Context, userID int64) (map[string]string, error) { bundle, err := LoadLarkBundle(ctx, m.vs, userID) if err != nil { return nil, fmt.Errorf("oauthcli: get lark env: %w", err) diff --git a/internal/oauthcli/vault.go b/internal/oauthcli/vault.go index 96dcf421..fb494403 100644 --- a/internal/oauthcli/vault.go +++ b/internal/oauthcli/vault.go @@ -13,64 +13,53 @@ type VaultStore interface { LoadEnv(ctx context.Context, userID int64) (map[string]string, error) } -// SaveGHBundle serializes bundle to JSON and stores it under VaultKeyGitHub. -func SaveGHBundle(ctx context.Context, vs VaultStore, userID int64, bundle GHOAuthBundle) error { +func saveBundle[T any](ctx context.Context, vs VaultStore, userID int64, key string, bundle T) error { data, err := json.Marshal(bundle) if err != nil { - return fmt.Errorf("oauthcli: marshal gh bundle: %w", err) + return fmt.Errorf("oauthcli: marshal bundle %q: %w", key, err) } - if err := vs.Set(ctx, userID, VaultKeyGitHub, string(data)); err != nil { - return fmt.Errorf("oauthcli: save gh bundle: %w", err) + if err := vs.Set(ctx, userID, key, string(data)); err != nil { + return fmt.Errorf("oauthcli: save bundle %q: %w", key, err) } return nil } -// LoadGHBundle retrieves and deserializes the GitHub token bundle for userID. -// Returns nil, nil if no entry exists yet. -func LoadGHBundle(ctx context.Context, vs VaultStore, userID int64) (*GHOAuthBundle, error) { +func loadBundle[T any](ctx context.Context, vs VaultStore, userID int64, key string) (*T, error) { env, err := vs.LoadEnv(ctx, userID) if err != nil { - return nil, fmt.Errorf("oauthcli: load gh bundle: %w", err) + return nil, fmt.Errorf("oauthcli: load bundle %q: %w", key, err) } - raw, ok := env[VaultKeyGitHub] + raw, ok := env[key] if !ok { return nil, nil } - var bundle GHOAuthBundle + var bundle T if err := json.Unmarshal([]byte(raw), &bundle); err != nil { - return nil, fmt.Errorf("oauthcli: unmarshal gh bundle: %w", err) + return nil, fmt.Errorf("oauthcli: unmarshal bundle %q: %w", key, err) } return &bundle, nil } +// SaveGHBundle serializes bundle to JSON and stores it under VaultKeyGitHub. +func SaveGHBundle(ctx context.Context, vs VaultStore, userID int64, bundle GHOAuthBundle) error { + return saveBundle(ctx, vs, userID, VaultKeyGitHub, bundle) +} + +// LoadGHBundle retrieves and deserializes the GitHub token bundle for userID. +// Returns nil, nil if no entry exists yet. +func LoadGHBundle(ctx context.Context, vs VaultStore, userID int64) (*GHOAuthBundle, error) { + return loadBundle[GHOAuthBundle](ctx, vs, userID, VaultKeyGitHub) +} + // SaveLarkBundle serializes bundle to JSON and stores it under VaultKeyLark. func SaveLarkBundle(ctx context.Context, vs VaultStore, userID int64, bundle LarkOAuthBundle) error { - data, err := json.Marshal(bundle) - if err != nil { - return fmt.Errorf("oauthcli: marshal lark bundle: %w", err) - } - if err := vs.Set(ctx, userID, VaultKeyLark, string(data)); err != nil { - return fmt.Errorf("oauthcli: save lark bundle: %w", err) - } - return nil + return saveBundle(ctx, vs, userID, VaultKeyLark, bundle) } // LoadLarkBundle retrieves and deserializes the Lark token bundle for userID. // Returns nil, nil if no entry exists yet. func LoadLarkBundle(ctx context.Context, vs VaultStore, userID int64) (*LarkOAuthBundle, error) { - env, err := vs.LoadEnv(ctx, userID) - if err != nil { - return nil, fmt.Errorf("oauthcli: load lark bundle: %w", err) - } - raw, ok := env[VaultKeyLark] - if !ok { - return nil, nil - } - var bundle LarkOAuthBundle - if err := json.Unmarshal([]byte(raw), &bundle); err != nil { - return nil, fmt.Errorf("oauthcli: unmarshal lark bundle: %w", err) - } - return &bundle, nil + return loadBundle[LarkOAuthBundle](ctx, vs, userID, VaultKeyLark) } // DeleteBundle removes the vault entry identified by key for userID. From bce0b4ca709c0520dba5baffbbcb59c05d9d0509 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 09:06:25 +0800 Subject: [PATCH 10/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(oauthcli):?= =?UTF-8?q?=20use=20oauth2=20library=20for=20all=20Lark=20token=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace custom postJSON token exchange and refresh with golang.org/x/oauth2: - Add larkTokenTransport: converts form-encoded→JSON requests, injects app_access_token as Bearer, and unwraps Lark's {"code":0,"data":{…}} envelope to flat OAuth2 JSON so the library can parse it normally. - LarkBroker.Complete: uses cfg.Exchange() via larkOAuthContext. - TokenManager.refreshLarkToken: uses cfg.TokenSource() pointed at Lark's dedicated refresh endpoint. - Extract fetchLarkAppToken and larkBundleFromToken as shared helpers; remove larkUserTokenResponse and larkRefreshResponse hand-rolled types. - larkBaseURL promoted to package-level (was LarkBroker method). Assisted-by: Claude Code:claude-sonnet-4-6 --- internal/oauthcli/lark.go | 197 ++++++++++++++++++++--------- internal/oauthcli/token_manager.go | 93 ++++++-------- 2 files changed, 174 insertions(+), 116 deletions(-) diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go index 77eab5e1..a1864ff3 100644 --- a/internal/oauthcli/lark.go +++ b/internal/oauthcli/lark.go @@ -1,8 +1,13 @@ package oauthcli import ( + "bytes" "context" + "encoding/json" "fmt" + "io" + "net/http" + "net/url" "time" "github.com/google/uuid" @@ -46,26 +51,128 @@ func (b *LarkBroker) WithRedirectURI(uri string) *LarkBroker { return &LarkBroker{cfg: b.cfg, store: b.store, redirectURI: uri} } -func (b *LarkBroker) baseURL() string { - if b.cfg.Brand == "feishu" { +func larkBaseURL(brand string) string { + if brand == "feishu" { return larkBaseFeishu } return larkBaseLark } func (b *LarkBroker) oauthConfig() *oauth2.Config { - base := b.baseURL() + base := larkBaseURL(b.cfg.Brand) return &oauth2.Config{ ClientID: b.cfg.AppID, RedirectURL: b.redirectURI, Scopes: []string{larkScope}, Endpoint: oauth2.Endpoint{ - AuthURL: base + "/open-apis/authen/v1/authorize", - TokenURL: base + "/open-apis/authen/v1/oidc/access_token", + AuthURL: base + "/open-apis/authen/v1/authorize", + TokenURL: base + "/open-apis/authen/v1/oidc/access_token", + AuthStyle: oauth2.AuthStyleInParams, }, } } +// larkTokenTransport adapts Lark's non-standard OIDC token endpoints for use +// with golang.org/x/oauth2. It: +// - converts the library's form-encoded request body to JSON (Lark expects JSON), +// - injects the app access token as Authorization: Bearer, +// - unwraps Lark's {"code":0,"data":{…}} envelope to flat OAuth2 JSON so the +// library can parse the response normally. +// +// refresh_expires_in is preserved as an extra field so callers can read it via +// tok.Extra("refresh_expires_in"). +type larkTokenTransport struct { + appToken string +} + +func (t *larkTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+t.appToken) + + // oauth2 sends form-encoded; Lark's OIDC endpoints expect JSON. + if req.Body != nil && req.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { + raw, err := io.ReadAll(req.Body) + req.Body.Close() + if err != nil { + return nil, err + } + vals, _ := url.ParseQuery(string(raw)) + m := make(map[string]string, len(vals)) + for k, vs := range vals { + if len(vs) > 0 { + m[k] = vs[0] + } + } + jsonBody, _ := json.Marshal(m) + req.Body = io.NopCloser(bytes.NewReader(jsonBody)) + req.ContentLength = int64(len(jsonBody)) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + } + + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return nil, err + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + var larkResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + } `json:"data"` + } + if err := json.Unmarshal(body, &larkResp); err != nil { + // Not a Lark envelope — pass through as-is. + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil + } + if larkResp.Code != 0 { + errBody := fmt.Appendf(nil, `{"error":"lark_%d","error_description":%q}`, larkResp.Code, larkResp.Msg) + resp.StatusCode = http.StatusBadRequest + resp.Body = io.NopCloser(bytes.NewReader(errBody)) + resp.ContentLength = int64(len(errBody)) + return resp, nil + } + + // Rewrite to flat OAuth2 JSON; include refresh_expires_in as an extra field + // so callers can read it via tok.Extra("refresh_expires_in"). + flat := struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + RefreshExpiresIn int `json:"refresh_expires_in"` + }{ + AccessToken: larkResp.Data.AccessToken, + RefreshToken: larkResp.Data.RefreshToken, + ExpiresIn: larkResp.Data.ExpiresIn, + TokenType: "Bearer", + RefreshExpiresIn: larkResp.Data.RefreshExpiresIn, + } + flatBody, _ := json.Marshal(flat) + resp.StatusCode = http.StatusOK + resp.Body = io.NopCloser(bytes.NewReader(flatBody)) + resp.ContentLength = int64(len(flatBody)) + return resp, nil +} + +// larkOAuthContext returns ctx with an HTTP client that routes Lark token +// requests through larkTokenTransport. +func larkOAuthContext(ctx context.Context, appToken string) context.Context { + return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ + Transport: &larkTokenTransport{appToken: appToken}, + }) +} + // StartDeviceFlow generates a state token, constructs the authorization URL, // and stores a pending FlowStatus. The user must navigate to VerificationURI. func (b *LarkBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowStatus, error) { @@ -106,21 +213,18 @@ func (b *LarkBroker) Complete(ctx context.Context, vs VaultStore, userID int64, return fmt.Errorf("oauthcli/lark: unknown flow %q", flowID) } - appToken, err := b.fetchAppAccessToken(ctx) + appToken, err := fetchLarkAppToken(ctx, larkBaseURL(b.cfg.Brand), b.cfg.AppID, b.cfg.AppSecret) if err != nil { return fmt.Errorf("oauthcli/lark: fetch app access token: %w", err) } - bundle, err := b.exchangeCode(ctx, appToken, code) + tok, err := b.oauthConfig().Exchange(larkOAuthContext(ctx, appToken), code) if err != nil { return fmt.Errorf("oauthcli/lark: exchange code: %w", err) } - bundle.AppID = b.cfg.AppID - bundle.AppSecret = b.cfg.AppSecret - bundle.Brand = b.cfg.Brand - bundle.Version = 1 - if err := SaveLarkBundle(ctx, vs, userID, *bundle); err != nil { + bundle := larkBundleFromToken(tok, b.cfg.AppID, b.cfg.AppSecret, b.cfg.Brand) + if err := SaveLarkBundle(ctx, vs, userID, bundle); err != nil { return err } @@ -137,17 +241,13 @@ type larkAppTokenResponse struct { Expire int `json:"expire"` } -// fetchAppAccessToken obtains a short-lived Lark app access token. -// Lark requires a separate app credential exchange before user token operations — -// the standard oauth2 flow does not support this pattern. -func (b *LarkBroker) fetchAppAccessToken(ctx context.Context) (string, error) { - endpoint := b.baseURL() + "/open-apis/auth/v3/app_access_token/internal" - reqBody := map[string]string{ - "app_id": b.cfg.AppID, - "app_secret": b.cfg.AppSecret, - } +// fetchLarkAppToken obtains a short-lived Lark app access token. +// Lark requires a separate server-to-server credential exchange before any +// user token operation — this step has no equivalent in standard OAuth2. +func fetchLarkAppToken(ctx context.Context, base, appID, appSecret string) (string, error) { + endpoint := base + "/open-apis/auth/v3/app_access_token/internal" var resp larkAppTokenResponse - if err := postJSON(ctx, endpoint, reqBody, nil, &resp); err != nil { + if err := postJSON(ctx, endpoint, map[string]string{"app_id": appID, "app_secret": appSecret}, nil, &resp); err != nil { return "", fmt.Errorf("app_access_token request: %w", err) } if resp.Code != 0 { @@ -156,44 +256,21 @@ func (b *LarkBroker) fetchAppAccessToken(ctx context.Context) (string, error) { return resp.AppAccessToken, nil } -// larkUserTokenResponse is the JSON body from the OIDC access_token endpoint. -type larkUserTokenResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - RefreshExpiresIn int `json:"refresh_expires_in"` - TokenType string `json:"token_type"` - } `json:"data"` -} - -// exchangeCode exchanges an authorization code for a user access token. -// Uses custom HTTP because Lark requires Authorization: Bearer , -// which the standard oauth2.Config.Exchange() does not support. -func (b *LarkBroker) exchangeCode(ctx context.Context, appToken string, code string) (*LarkOAuthBundle, error) { - endpoint := b.baseURL() + "/open-apis/authen/v1/oidc/access_token" - reqBody := map[string]string{ - "grant_type": "authorization_code", - "code": code, +// larkBundleFromToken builds a LarkOAuthBundle from an oauth2.Token returned +// by Exchange or TokenSource. The non-standard refresh_expires_in field is +// read from the token's extra claims. +func larkBundleFromToken(tok *oauth2.Token, appID, appSecret, brand string) LarkOAuthBundle { + bundle := LarkOAuthBundle{ + Version: 1, + AppID: appID, + AppSecret: appSecret, + Brand: brand, + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + AccessExpiresAt: tok.Expiry, } - headers := map[string]string{ - "Authorization": "Bearer " + appToken, + if ri, ok := tok.Extra("refresh_expires_in").(float64); ok && ri > 0 { + bundle.RefreshExpiresAt = time.Now().Add(time.Duration(ri) * time.Second) } - var resp larkUserTokenResponse - if err := postJSON(ctx, endpoint, reqBody, headers, &resp); err != nil { - return nil, fmt.Errorf("token exchange request: %w", err) - } - if resp.Code != 0 { - return nil, fmt.Errorf("token exchange error %d: %s", resp.Code, resp.Msg) - } - - now := time.Now() - return &LarkOAuthBundle{ - AccessToken: resp.Data.AccessToken, - RefreshToken: resp.Data.RefreshToken, - AccessExpiresAt: now.Add(time.Duration(resp.Data.ExpiresIn) * time.Second), - RefreshExpiresAt: now.Add(time.Duration(resp.Data.RefreshExpiresIn) * time.Second), - }, nil + return bundle } diff --git a/internal/oauthcli/token_manager.go b/internal/oauthcli/token_manager.go index 6c447959..d0640931 100644 --- a/internal/oauthcli/token_manager.go +++ b/internal/oauthcli/token_manager.go @@ -3,7 +3,10 @@ package oauthcli import ( "context" "fmt" + "net/http" "time" + + "golang.org/x/oauth2" ) // tokenExpirySafetyMargin is subtracted from expiry times to avoid using @@ -73,74 +76,52 @@ func (m *TokenManager) GetLarkRuntimeEnv(ctx context.Context, userID int64) (map bundle = refreshed } - env := map[string]string{ + return map[string]string{ "LARK_ACCESS_TOKEN": bundle.AccessToken, "LARK_APP_ID": bundle.AppID, "LARK_BRAND": bundle.Brand, - } - return env, nil -} - -// larkRefreshResponse is the JSON body from the OIDC refresh_access_token endpoint. -type larkRefreshResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - RefreshExpiresIn int `json:"refresh_expires_in"` - } `json:"data"` + }, nil } -// refreshLarkToken exchanges the bundle's refresh token for a new access -// token, returning an updated bundle. The original bundle is not mutated. +// refreshLarkToken fetches a new app access token, then uses the oauth2 +// library's TokenSource to refresh the user access token via Lark's OIDC +// refresh endpoint. larkTokenTransport adapts Lark's non-standard request +// and response format for the library. func (m *TokenManager) refreshLarkToken(ctx context.Context, bundle *LarkOAuthBundle) (*LarkOAuthBundle, error) { - base := larkBaseFeishu - if bundle.Brand == "lark" { - base = larkBaseLark - } + base := larkBaseURL(bundle.Brand) - // Obtain a fresh app access token using the stored credentials. - appTokenEndpoint := base + "/open-apis/auth/v3/app_access_token/internal" - appTokenBody := map[string]string{ - "app_id": bundle.AppID, - "app_secret": bundle.AppSecret, - } - var appTokenResp larkAppTokenResponse - if err := postJSON(ctx, appTokenEndpoint, appTokenBody, nil, &appTokenResp); err != nil { + appToken, err := fetchLarkAppToken(ctx, base, bundle.AppID, bundle.AppSecret) + if err != nil { return nil, fmt.Errorf("fetch app access token: %w", err) } - if appTokenResp.Code != 0 { - return nil, fmt.Errorf("app_access_token error %d: %s", appTokenResp.Code, appTokenResp.Msg) - } - // Refresh the user access token. - refreshEndpoint := base + "/open-apis/authen/v1/oidc/refresh_access_token" - refreshBody := map[string]string{ - "grant_type": "refresh_token", - "refresh_token": bundle.RefreshToken, - } - headers := map[string]string{ - "Authorization": "Bearer " + appTokenResp.AppAccessToken, - } - var refreshResp larkRefreshResponse - if err := postJSON(ctx, refreshEndpoint, refreshBody, headers, &refreshResp); err != nil { - return nil, fmt.Errorf("refresh token request: %w", err) - } - if refreshResp.Code != 0 { - return nil, fmt.Errorf("refresh token error %d: %s", refreshResp.Code, refreshResp.Msg) + cfg := &oauth2.Config{ + ClientID: bundle.AppID, + Endpoint: oauth2.Endpoint{ + // Lark uses a distinct endpoint for token refresh. + TokenURL: base + "/open-apis/authen/v1/oidc/refresh_access_token", + AuthStyle: oauth2.AuthStyleInParams, + }, + } + existing := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + Expiry: bundle.AccessExpiresAt, // expired → triggers refresh + } + refreshCtx := context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ + Transport: &larkTokenTransport{appToken: appToken}, + }) + tok, err := cfg.TokenSource(refreshCtx, existing).Token() + if err != nil { + return nil, fmt.Errorf("refresh token: %w", err) } - now := time.Now() - refreshed := *bundle // shallow copy; all fields are value types or immutable strings - refreshed.AccessToken = refreshResp.Data.AccessToken - if refreshResp.Data.RefreshToken != "" { - refreshed.RefreshToken = refreshResp.Data.RefreshToken - } - refreshed.AccessExpiresAt = now.Add(time.Duration(refreshResp.Data.ExpiresIn) * time.Second) - if refreshResp.Data.RefreshExpiresIn > 0 { - refreshed.RefreshExpiresAt = now.Add(time.Duration(refreshResp.Data.RefreshExpiresIn) * time.Second) + refreshed := larkBundleFromToken(tok, bundle.AppID, bundle.AppSecret, bundle.Brand) + // larkBundleFromToken sets Version to 1; preserve the existing version. + refreshed.Version = bundle.Version + // Only update RefreshExpiresAt if the server returned a new value. + if refreshed.RefreshExpiresAt.IsZero() { + refreshed.RefreshExpiresAt = bundle.RefreshExpiresAt } return &refreshed, nil } From 324799dacbe83494c17497ca5da9cffe1b02ccdb Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 09:13:59 +0800 Subject: [PATCH 11/21] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(oauthcli):?= =?UTF-8?q?=20switch=20Lark=20to=20v2=20OAuth=20endpoint,=20remove=20custo?= =?UTF-8?q?m=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lark's v2 token endpoint (/open-apis/authen/v2/oauth/token) follows standard OAuth2: form-encoded requests with client_id/client_secret in body, flat JSON responses. No app access token pre-fetch is required. - Remove larkTokenTransport, larkOAuthContext, fetchLarkAppToken, larkAppTokenResponse — all workarounds for the old v1 OIDC endpoints. - Delete http.go (postJSON helper, now unused). - oauthConfig: add ClientSecret, point TokenURL at v2 endpoint. - Complete: plain cfg.Exchange(ctx, code) — no custom HTTP client. - refreshLarkToken: plain cfg.TokenSource(ctx, existing).Token() — no app token step, no custom transport. - Update extra field key refresh_expires_in → refresh_token_expires_in (v2 response field name). Assisted-by: Claude Code:claude-sonnet-4-6 --- internal/oauthcli/http.go | 34 ------- internal/oauthcli/lark.go | 157 +++-------------------------- internal/oauthcli/token_manager.go | 29 ++---- 3 files changed, 22 insertions(+), 198 deletions(-) delete mode 100644 internal/oauthcli/http.go diff --git a/internal/oauthcli/http.go b/internal/oauthcli/http.go deleted file mode 100644 index 3e653608..00000000 --- a/internal/oauthcli/http.go +++ /dev/null @@ -1,34 +0,0 @@ -package oauthcli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" -) - -// postJSON POSTs a JSON body and decodes the JSON response into out. -func postJSON(ctx context.Context, url string, reqBody any, headers map[string]string, out any) error { - data, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("build request: %w", err) - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - for k, v := range headers { - req.Header.Set(k, v) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("do request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("unexpected status %d", resp.StatusCode) - } - return json.NewDecoder(resp.Body).Decode(out) -} diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go index a1864ff3..df739d46 100644 --- a/internal/oauthcli/lark.go +++ b/internal/oauthcli/lark.go @@ -1,13 +1,8 @@ package oauthcli import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" "time" "github.com/google/uuid" @@ -58,121 +53,24 @@ func larkBaseURL(brand string) string { return larkBaseLark } +// oauthConfig returns an oauth2.Config for Lark's v2 token endpoint, which +// follows standard OAuth2: form-encoded requests with client credentials in +// the body, flat JSON responses. No app access token pre-fetch is required. func (b *LarkBroker) oauthConfig() *oauth2.Config { base := larkBaseURL(b.cfg.Brand) return &oauth2.Config{ - ClientID: b.cfg.AppID, - RedirectURL: b.redirectURI, - Scopes: []string{larkScope}, + ClientID: b.cfg.AppID, + ClientSecret: b.cfg.AppSecret, + RedirectURL: b.redirectURI, + Scopes: []string{larkScope}, Endpoint: oauth2.Endpoint{ AuthURL: base + "/open-apis/authen/v1/authorize", - TokenURL: base + "/open-apis/authen/v1/oidc/access_token", + TokenURL: base + "/open-apis/authen/v2/oauth/token", AuthStyle: oauth2.AuthStyleInParams, }, } } -// larkTokenTransport adapts Lark's non-standard OIDC token endpoints for use -// with golang.org/x/oauth2. It: -// - converts the library's form-encoded request body to JSON (Lark expects JSON), -// - injects the app access token as Authorization: Bearer, -// - unwraps Lark's {"code":0,"data":{…}} envelope to flat OAuth2 JSON so the -// library can parse the response normally. -// -// refresh_expires_in is preserved as an extra field so callers can read it via -// tok.Extra("refresh_expires_in"). -type larkTokenTransport struct { - appToken string -} - -func (t *larkTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - req.Header.Set("Authorization", "Bearer "+t.appToken) - - // oauth2 sends form-encoded; Lark's OIDC endpoints expect JSON. - if req.Body != nil && req.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { - raw, err := io.ReadAll(req.Body) - req.Body.Close() - if err != nil { - return nil, err - } - vals, _ := url.ParseQuery(string(raw)) - m := make(map[string]string, len(vals)) - for k, vs := range vals { - if len(vs) > 0 { - m[k] = vs[0] - } - } - jsonBody, _ := json.Marshal(m) - req.Body = io.NopCloser(bytes.NewReader(jsonBody)) - req.ContentLength = int64(len(jsonBody)) - req.Header.Set("Content-Type", "application/json; charset=utf-8") - } - - resp, err := http.DefaultTransport.RoundTrip(req) - if err != nil { - return nil, err - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, err - } - - var larkResp struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - RefreshExpiresIn int `json:"refresh_expires_in"` - } `json:"data"` - } - if err := json.Unmarshal(body, &larkResp); err != nil { - // Not a Lark envelope — pass through as-is. - resp.Body = io.NopCloser(bytes.NewReader(body)) - return resp, nil - } - if larkResp.Code != 0 { - errBody := fmt.Appendf(nil, `{"error":"lark_%d","error_description":%q}`, larkResp.Code, larkResp.Msg) - resp.StatusCode = http.StatusBadRequest - resp.Body = io.NopCloser(bytes.NewReader(errBody)) - resp.ContentLength = int64(len(errBody)) - return resp, nil - } - - // Rewrite to flat OAuth2 JSON; include refresh_expires_in as an extra field - // so callers can read it via tok.Extra("refresh_expires_in"). - flat := struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - TokenType string `json:"token_type"` - RefreshExpiresIn int `json:"refresh_expires_in"` - }{ - AccessToken: larkResp.Data.AccessToken, - RefreshToken: larkResp.Data.RefreshToken, - ExpiresIn: larkResp.Data.ExpiresIn, - TokenType: "Bearer", - RefreshExpiresIn: larkResp.Data.RefreshExpiresIn, - } - flatBody, _ := json.Marshal(flat) - resp.StatusCode = http.StatusOK - resp.Body = io.NopCloser(bytes.NewReader(flatBody)) - resp.ContentLength = int64(len(flatBody)) - return resp, nil -} - -// larkOAuthContext returns ctx with an HTTP client that routes Lark token -// requests through larkTokenTransport. -func larkOAuthContext(ctx context.Context, appToken string) context.Context { - return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ - Transport: &larkTokenTransport{appToken: appToken}, - }) -} - // StartDeviceFlow generates a state token, constructs the authorization URL, // and stores a pending FlowStatus. The user must navigate to VerificationURI. func (b *LarkBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowStatus, error) { @@ -213,17 +111,13 @@ func (b *LarkBroker) Complete(ctx context.Context, vs VaultStore, userID int64, return fmt.Errorf("oauthcli/lark: unknown flow %q", flowID) } - appToken, err := fetchLarkAppToken(ctx, larkBaseURL(b.cfg.Brand), b.cfg.AppID, b.cfg.AppSecret) - if err != nil { - return fmt.Errorf("oauthcli/lark: fetch app access token: %w", err) - } - - tok, err := b.oauthConfig().Exchange(larkOAuthContext(ctx, appToken), code) + tok, err := b.oauthConfig().Exchange(ctx, code) if err != nil { return fmt.Errorf("oauthcli/lark: exchange code: %w", err) } bundle := larkBundleFromToken(tok, b.cfg.AppID, b.cfg.AppSecret, b.cfg.Brand) + bundle.Version = 1 if err := SaveLarkBundle(ctx, vs, userID, bundle); err != nil { return err } @@ -233,35 +127,12 @@ func (b *LarkBroker) Complete(ctx context.Context, vs VaultStore, userID int64, return nil } -// larkAppTokenResponse is the JSON body from the app_access_token endpoint. -type larkAppTokenResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - AppAccessToken string `json:"app_access_token"` - Expire int `json:"expire"` -} - -// fetchLarkAppToken obtains a short-lived Lark app access token. -// Lark requires a separate server-to-server credential exchange before any -// user token operation — this step has no equivalent in standard OAuth2. -func fetchLarkAppToken(ctx context.Context, base, appID, appSecret string) (string, error) { - endpoint := base + "/open-apis/auth/v3/app_access_token/internal" - var resp larkAppTokenResponse - if err := postJSON(ctx, endpoint, map[string]string{"app_id": appID, "app_secret": appSecret}, nil, &resp); err != nil { - return "", fmt.Errorf("app_access_token request: %w", err) - } - if resp.Code != 0 { - return "", fmt.Errorf("app_access_token error %d: %s", resp.Code, resp.Msg) - } - return resp.AppAccessToken, nil -} - // larkBundleFromToken builds a LarkOAuthBundle from an oauth2.Token returned -// by Exchange or TokenSource. The non-standard refresh_expires_in field is -// read from the token's extra claims. +// by Exchange or TokenSource. Version is left at zero; callers must set it. +// refresh_token_expires_in is a non-standard Lark field preserved as a token +// extra so callers can read it via tok.Extra("refresh_token_expires_in"). func larkBundleFromToken(tok *oauth2.Token, appID, appSecret, brand string) LarkOAuthBundle { bundle := LarkOAuthBundle{ - Version: 1, AppID: appID, AppSecret: appSecret, Brand: brand, @@ -269,7 +140,7 @@ func larkBundleFromToken(tok *oauth2.Token, appID, appSecret, brand string) Lark RefreshToken: tok.RefreshToken, AccessExpiresAt: tok.Expiry, } - if ri, ok := tok.Extra("refresh_expires_in").(float64); ok && ri > 0 { + if ri, ok := tok.Extra("refresh_token_expires_in").(float64); ok && ri > 0 { bundle.RefreshExpiresAt = time.Now().Add(time.Duration(ri) * time.Second) } return bundle diff --git a/internal/oauthcli/token_manager.go b/internal/oauthcli/token_manager.go index d0640931..96c53616 100644 --- a/internal/oauthcli/token_manager.go +++ b/internal/oauthcli/token_manager.go @@ -3,12 +3,12 @@ package oauthcli import ( "context" "fmt" - "net/http" "time" "golang.org/x/oauth2" ) + // tokenExpirySafetyMargin is subtracted from expiry times to avoid using // tokens that are about to expire during a long-running operation. const tokenExpirySafetyMargin = 2 * time.Minute @@ -83,23 +83,15 @@ func (m *TokenManager) GetLarkRuntimeEnv(ctx context.Context, userID int64) (map }, nil } -// refreshLarkToken fetches a new app access token, then uses the oauth2 -// library's TokenSource to refresh the user access token via Lark's OIDC -// refresh endpoint. larkTokenTransport adapts Lark's non-standard request -// and response format for the library. +// refreshLarkToken uses the oauth2 library's TokenSource to refresh the user +// access token against Lark's v2 token endpoint (standard OAuth2 form-encoded, +// no app access token pre-fetch required). func (m *TokenManager) refreshLarkToken(ctx context.Context, bundle *LarkOAuthBundle) (*LarkOAuthBundle, error) { - base := larkBaseURL(bundle.Brand) - - appToken, err := fetchLarkAppToken(ctx, base, bundle.AppID, bundle.AppSecret) - if err != nil { - return nil, fmt.Errorf("fetch app access token: %w", err) - } - cfg := &oauth2.Config{ - ClientID: bundle.AppID, + ClientID: bundle.AppID, + ClientSecret: bundle.AppSecret, Endpoint: oauth2.Endpoint{ - // Lark uses a distinct endpoint for token refresh. - TokenURL: base + "/open-apis/authen/v1/oidc/refresh_access_token", + TokenURL: larkBaseURL(bundle.Brand) + "/open-apis/authen/v2/oauth/token", AuthStyle: oauth2.AuthStyleInParams, }, } @@ -108,18 +100,13 @@ func (m *TokenManager) refreshLarkToken(ctx context.Context, bundle *LarkOAuthBu RefreshToken: bundle.RefreshToken, Expiry: bundle.AccessExpiresAt, // expired → triggers refresh } - refreshCtx := context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ - Transport: &larkTokenTransport{appToken: appToken}, - }) - tok, err := cfg.TokenSource(refreshCtx, existing).Token() + tok, err := cfg.TokenSource(ctx, existing).Token() if err != nil { return nil, fmt.Errorf("refresh token: %w", err) } refreshed := larkBundleFromToken(tok, bundle.AppID, bundle.AppSecret, bundle.Brand) - // larkBundleFromToken sets Version to 1; preserve the existing version. refreshed.Version = bundle.Version - // Only update RefreshExpiresAt if the server returned a new value. if refreshed.RefreshExpiresAt.IsZero() { refreshed.RefreshExpiresAt = bundle.RefreshExpiresAt } From 9f30a36ee7a0372438cd33198f3df4f98771138d Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 09:15:35 +0800 Subject: [PATCH 12/21] =?UTF-8?q?=E2=9C=A8=20feat(oauthcli):=20expand=20Gi?= =?UTF-8?q?tHub=20OAuth=20scopes=20for=20human-level=20CLI=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add workflow (push Actions files), gist, and user scopes alongside the existing repo and read:org. Matches the scope set used by the gh CLI. Switch from a single comma-joined const to a proper []string var so the oauth2 library sends each scope individually. Assisted-by: Claude Code:claude-sonnet-4-6 --- internal/oauthcli/gh.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/oauthcli/gh.go b/internal/oauthcli/gh.go index 417d3af3..3161ccc7 100644 --- a/internal/oauthcli/gh.go +++ b/internal/oauthcli/gh.go @@ -11,7 +11,9 @@ import ( "golang.org/x/oauth2/github" ) -const ghScope = "repo,read:org" +// ghScopes mirrors the scopes requested by the gh CLI for human-level access: +// repo (full repo r/w including push), workflow (Actions files), gist, user, read:org. +var ghScopes = []string{"repo", "workflow", "gist", "user", "read:org"} // GitHubConfig holds the OAuth app credentials for device flow. type GitHubConfig struct { @@ -48,7 +50,7 @@ func (b *GitHubBroker) oauthConfig() *oauth2.Config { return &oauth2.Config{ ClientID: b.cfg.ClientID, ClientSecret: b.cfg.ClientSecret, - Scopes: []string{ghScope}, + Scopes: ghScopes, Endpoint: github.Endpoint, } } From 40787ce42af010a17d6ade135127fc78fcc74d3b Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 09:18:22 +0800 Subject: [PATCH 13/21] =?UTF-8?q?=E2=9C=A8=20feat(oauthcli):=20add=20offli?= =?UTF-8?q?ne=5Faccess=20scope=20and=20fix=20Lark=20scope=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lark-cli always appends offline_access to ensure the server issues a refresh token. Without it the v2 endpoint may skip the refresh token, breaking token renewal entirely. Also switch from a single const string to a proper []string var (matching the GitHub change) so the oauth2 library sends each scope individually. Assisted-by: Claude Code:claude-sonnet-4-6 --- internal/oauthcli/lark.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go index df739d46..45cc92cb 100644 --- a/internal/oauthcli/lark.go +++ b/internal/oauthcli/lark.go @@ -14,9 +14,13 @@ const ( larkBaseLark = "https://open.larksuite.com" larkRedirectURI = "https://anna.app/oauth/lark/callback" // placeholder; caller sets up the real redirect - larkScope = "contact:user.base:readonly" ) +// larkScopes are the OAuth scopes requested at login. +// offline_access is required for the server to issue a refresh token. +// contact:user.base:readonly provides basic identity info for the session. +var larkScopes = []string{"offline_access", "contact:user.base:readonly"} + // LarkConfig holds the OAuth app credentials for Lark/Feishu device-style flow. type LarkConfig struct { AppID string @@ -62,7 +66,7 @@ func (b *LarkBroker) oauthConfig() *oauth2.Config { ClientID: b.cfg.AppID, ClientSecret: b.cfg.AppSecret, RedirectURL: b.redirectURI, - Scopes: []string{larkScope}, + Scopes: larkScopes, Endpoint: oauth2.Endpoint{ AuthURL: base + "/open-apis/authen/v1/authorize", TokenURL: base + "/open-apis/authen/v2/oauth/token", From 02f486b172413420a4daedb21f901b4a3d9f9119 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 09:23:41 +0800 Subject: [PATCH 14/21] =?UTF-8?q?=E2=9C=A8=20feat(oauthcli):=20expand=20La?= =?UTF-8?q?rk=20scopes=20for=20daily=20Feishu=20life=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the common use cases: IM read/send, calendar, all document types (docs, docx, wiki, sheets, slides), tasks, and file downloads. Scope choices: - im:message + im:chat:readonly — user-level messaging (not bot) - calendar:calendar:readonly — read events/free-busy - drive:file:download + drive:drive.metadata:readonly - docs:document.content:read + comment + export (legacy format) - docx:document, wiki:wiki, sheets:spreadsheet (combined read/write) - slides:presentation:read + update - task:task + task:comment read/write Assisted-by: Claude Code:claude-sonnet-4-6 --- internal/oauthcli/lark.go | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go index 45cc92cb..3375e924 100644 --- a/internal/oauthcli/lark.go +++ b/internal/oauthcli/lark.go @@ -16,10 +16,40 @@ const ( larkRedirectURI = "https://anna.app/oauth/lark/callback" // placeholder; caller sets up the real redirect ) -// larkScopes are the OAuth scopes requested at login. -// offline_access is required for the server to issue a refresh token. -// contact:user.base:readonly provides basic identity info for the session. -var larkScopes = []string{"offline_access", "contact:user.base:readonly"} +// larkScopes covers common Feishu/Lark daily-use scenarios: IM read/send, +// calendar, all document types (docs, docx, wiki, sheets, slides), tasks, +// and file access. offline_access is required for refresh tokens. +var larkScopes = []string{ + "offline_access", + // Identity + "contact:user.base:readonly", + // IM — read and send messages as user, read chats + "im:message", + "im:chat:readonly", + // Calendar — read events and free/busy + "calendar:calendar:readonly", + // Drive — download files and read metadata + "drive:file:download", + "drive:drive.metadata:readonly", + // Docs (legacy doc format) — read content, comments, export + "docs:document.content:read", + "docs:document.comment:read", + "docs:document.comment:create", + "docs:document:export", + // Docx (new document format) — full read/write + "docx:document", + // Wiki — full read/write + "wiki:wiki", + // Sheets — full read/write + "sheets:spreadsheet", + // Slides — read and update presentations + "slides:presentation:read", + "slides:presentation:update", + // Tasks — full read/write with comments + "task:task", + "task:comment:read", + "task:comment:write", +} // LarkConfig holds the OAuth app credentials for Lark/Feishu device-style flow. type LarkConfig struct { From d5c36faf28928f4057a551b0e3f68449f6a873a1 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 09:29:33 +0800 Subject: [PATCH 15/21] =?UTF-8?q?=F0=9F=90=9B=20fix(config):=20seed=20auth?= =?UTF-8?q?/github=20and=20auth/lark=20plugins=20on=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both plugins were defined and imported but never inserted into settings_plugins, causing "sql: no rows in result set" whenever the admin OAuth routes called pluginHost.Config().Get(). - Add PluginKindAuth = "auth" constant - Add builtinAuthNames = ["github", "lark"] - Include auth plugins in BuiltinPluginIDs() - Call seedBuiltinPlugins for auth kind in seedPlugins() Auth plugins seed with enabled=0 (disabled by default, requiring explicit configuration before activation). Assisted-by: Claude Code:claude-sonnet-4-6 --- internal/config/dbstore.go | 4 ++++ internal/config/plugin.go | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/config/dbstore.go b/internal/config/dbstore.go index 1a9624ad..901991de 100644 --- a/internal/config/dbstore.go +++ b/internal/config/dbstore.go @@ -633,6 +633,10 @@ func (s *DBStore) seedPlugins(ctx context.Context) error { return err } + if err := s.seedBuiltinPlugins(ctx, PluginKindAuth, builtinAuthNames, nil); err != nil { + return err + } + // Seed the reflect plugin (conversation review). if err := s.q.SeedPlugin(ctx, sqlc.SeedPluginParams{ ID: "reflect", diff --git a/internal/config/plugin.go b/internal/config/plugin.go index 1a82bee3..edc52326 100644 --- a/internal/config/plugin.go +++ b/internal/config/plugin.go @@ -8,6 +8,7 @@ const ( PluginKindProvider = "provider" PluginKindMemory = "memory" PluginKindSandbox = "sandbox" + PluginKindAuth = "auth" ) // Plugin represents a unified plugin entry stored in settings_plugins. @@ -44,13 +45,16 @@ var builtinMemoryNames = []string{"lcm", "simple"} // has been removed; enabling them will fail at runtime with a clear error. var builtinSandboxNames = []string{SandboxBackendDocker, SandboxBackendBoxsh, SandboxBackendLocal} +// builtinAuthNames lists the built-in OAuth provider plugins. +var builtinAuthNames = []string{"github", "lark"} + // builtinStandalonePlugins lists plugins that don't follow the kind/name pattern. var builtinStandalonePlugins = []string{"reflect"} // BuiltinPluginIDs returns all built-in plugin IDs in deterministic order. // Provider instances are stored separately in settings_providers. func BuiltinPluginIDs() []string { - ids := make([]string, 0, len(builtinToolNames)+len(builtinChannelNames)+len(builtinHookNames)+len(builtinMemoryNames)+len(builtinSandboxNames)+len(builtinStandalonePlugins)) + ids := make([]string, 0, len(builtinToolNames)+len(builtinChannelNames)+len(builtinHookNames)+len(builtinMemoryNames)+len(builtinSandboxNames)+len(builtinAuthNames)+len(builtinStandalonePlugins)) for _, n := range builtinToolNames { ids = append(ids, PluginID(PluginKindTool, n)) } @@ -66,6 +70,9 @@ func BuiltinPluginIDs() []string { for _, n := range builtinSandboxNames { ids = append(ids, PluginID(PluginKindSandbox, n)) } + for _, n := range builtinAuthNames { + ids = append(ids, PluginID(PluginKindAuth, n)) + } ids = append(ids, builtinStandalonePlugins...) return ids } From cc5733b015c051d260863cb1d47cddad8a87756c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 10:10:14 +0800 Subject: [PATCH 16/21] =?UTF-8?q?=E2=9C=A8=20feat(oauthcli):=20add=20redir?= =?UTF-8?q?ect=5Furl=20config=20and=20fix=20Lark=20callback=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional redirect_url field to auth/github and auth/lark plugin configs; falls back to {server}/api/auth/profile/oauth/{provider}/callback - Add WithRedirectURI to GitHubBroker (matching LarkBroker) - Invalidate cached brokers when redirect_url changes - Store UserID in FlowStatus at flow-start time - Exempt Lark callback from authMiddleware; resolve user via flow store state param so the redirect works without a session cookie Assisted-by: claude-sonnet-4-6 --- internal/admin/middleware.go | 5 +++-- internal/admin/oauth.go | 38 +++++++++++++++++++++++++---------- internal/admin/server.go | 14 +++++++------ internal/oauthcli/gh.go | 16 +++++++++++---- internal/oauthcli/lark.go | 1 + internal/oauthcli/types.go | 1 + plugins/auth/github/plugin.go | 13 ++++++++++++ plugins/auth/lark/plugin.go | 25 +++++++++++++++++------ 8 files changed, 84 insertions(+), 29 deletions(-) diff --git a/internal/admin/middleware.go b/internal/admin/middleware.go index d24fc6c5..13629795 100644 --- a/internal/admin/middleware.go +++ b/internal/admin/middleware.go @@ -41,12 +41,13 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path - // Exempt paths: login page, static assets, auth login/register/logout. + // Exempt paths: login page, static assets, auth endpoints, OAuth callbacks. if path == "/login" || strings.HasPrefix(path, "/static/") || path == "/api/auth/login" || path == "/api/auth/register" || - path == "/api/auth/logout" { + path == "/api/auth/logout" || + path == "/api/auth/profile/oauth/lark/callback" { next.ServeHTTP(w, r) return } diff --git a/internal/admin/oauth.go b/internal/admin/oauth.go index 54e0ef5f..f2707689 100644 --- a/internal/admin/oauth.go +++ b/internal/admin/oauth.go @@ -17,6 +17,9 @@ const ( // It must match the redirect_uri configured in the Lark app. const larkCallbackPath = "/api/auth/profile/oauth/lark/callback" +// ghCallbackPath is the path GitHub redirects back to after user authorization. +const ghCallbackPath = "/api/auth/profile/oauth/github/callback" + // getGitHubBroker returns the cached GitHubBroker, lazily constructing it from // the current plugin config. Returns an error if the plugin is not configured. func (s *Server) getGitHubBroker(ctx context.Context) (*oauthcli.GitHubBroker, error) { @@ -29,15 +32,20 @@ func (s *Server) getGitHubBroker(ctx context.Context) (*oauthcli.GitHubBroker, e if clientID == "" || clientSecret == "" { return nil, fmt.Errorf("github OAuth app is not configured (set client_id and client_secret in auth/github plugin)") } + redirectURI, _ := state.Config["redirect_url"].(string) + if redirectURI == "" { + redirectURI = s.corsOriginV + ghCallbackPath + } s.oauthMu.Lock() defer s.oauthMu.Unlock() - if s.ghBroker == nil || s.ghBrokerClientID != clientID { + if s.ghBroker == nil || s.ghBrokerClientID != clientID || s.ghBrokerRedirectURI != redirectURI { s.ghBroker = oauthcli.NewGitHubBroker(oauthcli.GitHubConfig{ ClientID: clientID, ClientSecret: clientSecret, - }, s.flowStore) + }, s.flowStore).WithRedirectURI(redirectURI) s.ghBrokerClientID = clientID + s.ghBrokerRedirectURI = redirectURI } return s.ghBroker, nil } @@ -59,16 +67,21 @@ func (s *Server) getLarkBroker(ctx context.Context) (*oauthcli.LarkBroker, error brand = "lark" } + redirectURI, _ := state.Config["redirect_url"].(string) + if redirectURI == "" { + redirectURI = s.corsOriginV + larkCallbackPath + } + s.oauthMu.Lock() defer s.oauthMu.Unlock() - if s.larkBroker == nil || s.larkBrokerAppID != appID { - redirectURI := s.corsOriginV + larkCallbackPath + if s.larkBroker == nil || s.larkBrokerAppID != appID || s.larkBrokerRedirectURI != redirectURI { s.larkBroker = oauthcli.NewLarkBroker(oauthcli.LarkConfig{ AppID: appID, AppSecret: appSecret, Brand: brand, }, s.flowStore).WithRedirectURI(redirectURI) s.larkBrokerAppID = appID + s.larkBrokerRedirectURI = redirectURI } return s.larkBroker, nil } @@ -294,16 +307,13 @@ func (s *Server) disconnectOAuth(w http.ResponseWriter, r *http.Request) { // larkOAuthCallback handles GET /api/auth/profile/oauth/lark/callback. // Lark redirects the browser here after the user authorizes the app. // Query params: code=&state= +// This handler is exempt from authMiddleware; userID is resolved from the +// flow store via the state param so no session cookie is required. func (s *Server) larkOAuthCallback(w http.ResponseWriter, r *http.Request) { if s.vaultSvc == nil { http.Error(w, "vault not configured", http.StatusServiceUnavailable) return } - info := UserFromContext(r.Context()) - if info == nil { - http.Redirect(w, r, "/login", http.StatusFound) - return - } code := r.URL.Query().Get("code") flowID := r.URL.Query().Get("state") @@ -312,6 +322,12 @@ func (s *Server) larkOAuthCallback(w http.ResponseWriter, r *http.Request) { return } + flow, ok := s.flowStore.Get(flowID) + if !ok { + http.Error(w, "unknown or expired flow", http.StatusBadRequest) + return + } + ctx := r.Context() broker, err := s.getLarkBroker(ctx) if err != nil { @@ -319,8 +335,8 @@ func (s *Server) larkOAuthCallback(w http.ResponseWriter, r *http.Request) { return } - if err := broker.Complete(ctx, s.vaultSvc, info.UserID, flowID, code); err != nil { - s.log.Error("lark oauth complete", "user_id", info.UserID, "flow_id", flowID, "error", err) + if err := broker.Complete(ctx, s.vaultSvc, flow.UserID, flowID, code); err != nil { + s.log.Error("lark oauth complete", "user_id", flow.UserID, "flow_id", flowID, "error", err) http.Error(w, "failed to complete Lark authorization: "+err.Error(), http.StatusInternalServerError) return } diff --git a/internal/admin/server.go b/internal/admin/server.go index fe4e39f8..b9c4e5ff 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -38,12 +38,14 @@ type Server struct { vaultSvc *vault.Service // optional; if nil, vault endpoints return 503 // OAuth CLI device-flow state. - flowStore *oauthcli.FlowStore - oauthMu sync.Mutex - ghBroker *oauthcli.GitHubBroker // lazily initialised; guarded by oauthMu - ghBrokerClientID string // tracks which client_id ghBroker was built with - larkBroker *oauthcli.LarkBroker // lazily initialised; guarded by oauthMu - larkBrokerAppID string // tracks which app_id larkBroker was built with + flowStore *oauthcli.FlowStore + oauthMu sync.Mutex + ghBroker *oauthcli.GitHubBroker // lazily initialised; guarded by oauthMu + ghBrokerClientID string // tracks which client_id ghBroker was built with + ghBrokerRedirectURI string // tracks which redirect_url ghBroker was built with + larkBroker *oauthcli.LarkBroker // lazily initialised; guarded by oauthMu + larkBrokerAppID string // tracks which app_id larkBroker was built with + larkBrokerRedirectURI string // tracks which redirect_url larkBroker was built with } // New creates an admin server with all API routes mounted. diff --git a/internal/oauthcli/gh.go b/internal/oauthcli/gh.go index 3161ccc7..ca34db71 100644 --- a/internal/oauthcli/gh.go +++ b/internal/oauthcli/gh.go @@ -31,10 +31,11 @@ type ghFlowSecret struct { // GitHubBroker manages GitHub device-flow sessions. type GitHubBroker struct { - cfg GitHubConfig - store *FlowStore - mu sync.Mutex - secret map[string]*ghFlowSecret // flowID → secrets + cfg GitHubConfig + store *FlowStore + redirectURI string + mu sync.Mutex + secret map[string]*ghFlowSecret // flowID → secrets } // NewGitHubBroker constructs a GitHubBroker. @@ -46,10 +47,16 @@ func NewGitHubBroker(cfg GitHubConfig, store *FlowStore) *GitHubBroker { } } +// WithRedirectURI returns a new broker with the given redirect URI. +func (b *GitHubBroker) WithRedirectURI(uri string) *GitHubBroker { + return &GitHubBroker{cfg: b.cfg, store: b.store, redirectURI: uri, secret: b.secret} +} + func (b *GitHubBroker) oauthConfig() *oauth2.Config { return &oauth2.Config{ ClientID: b.cfg.ClientID, ClientSecret: b.cfg.ClientSecret, + RedirectURL: b.redirectURI, Scopes: ghScopes, Endpoint: github.Endpoint, } @@ -72,6 +79,7 @@ func (b *GitHubBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowS status := FlowStatus{ Provider: ProviderGitHub, FlowID: flowID, + UserID: userID, VerificationURI: da.VerificationURIComplete, UserCode: da.UserCode, ExpiresAt: expiresAt, diff --git a/internal/oauthcli/lark.go b/internal/oauthcli/lark.go index 3375e924..009bb897 100644 --- a/internal/oauthcli/lark.go +++ b/internal/oauthcli/lark.go @@ -112,6 +112,7 @@ func (b *LarkBroker) StartDeviceFlow(ctx context.Context, userID int64) (FlowSta status := FlowStatus{ Provider: ProviderLark, FlowID: flowID, + UserID: userID, VerificationURI: b.oauthConfig().AuthCodeURL(flowID), ExpiresAt: time.Now().Add(10 * time.Minute), State: FlowStatePending, diff --git a/internal/oauthcli/types.go b/internal/oauthcli/types.go index 25b5c77f..e5d40021 100644 --- a/internal/oauthcli/types.go +++ b/internal/oauthcli/types.go @@ -24,6 +24,7 @@ const ( type FlowStatus struct { Provider Provider FlowID string + UserID int64 VerificationURI string UserCode string ExpiresAt time.Time diff --git a/plugins/auth/github/plugin.go b/plugins/auth/github/plugin.go index 331cfbb4..89dde6ee 100644 --- a/plugins/auth/github/plugin.go +++ b/plugins/auth/github/plugin.go @@ -12,12 +12,14 @@ const PluginID = "auth/github" type Config struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` + RedirectURL string `json:"redirect_url"` } func defaultConfig() map[string]any { return map[string]any{ "client_id": "", "client_secret": "", + "redirect_url": "", } } @@ -33,6 +35,10 @@ func configSchema() map[string]any { "type": "string", "description": "GitHub OAuth app client secret.", }, + "redirect_url": map[string]any{ + "type": "string", + "description": "OAuth redirect URI registered in the GitHub app. Leave empty to use the server default: {your-server}/api/auth/profile/oauth/github/callback.", + }, }, "required": []any{"client_id", "client_secret"}, } @@ -54,6 +60,13 @@ func decodeConfig(raw map[string]any) (Config, error) { } cfg.ClientSecret = s } + if v, ok := raw["redirect_url"]; ok { + s, ok := v.(string) + if !ok { + return Config{}, fmt.Errorf("redirect_url: must be a string") + } + cfg.RedirectURL = s + } return cfg, nil } diff --git a/plugins/auth/lark/plugin.go b/plugins/auth/lark/plugin.go index 38c14830..f1cbd879 100644 --- a/plugins/auth/lark/plugin.go +++ b/plugins/auth/lark/plugin.go @@ -10,16 +10,18 @@ import ( const PluginID = "auth/lark" type Config struct { - AppID string `json:"app_id"` - AppSecret string `json:"app_secret"` - Brand string `json:"brand"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + Brand string `json:"brand"` + RedirectURL string `json:"redirect_url"` } func defaultConfig() map[string]any { return map[string]any{ - "app_id": "", - "app_secret": "", - "brand": "lark", + "app_id": "", + "app_secret": "", + "brand": "lark", + "redirect_url": "", } } @@ -41,6 +43,10 @@ func configSchema() map[string]any { "description": "Platform brand: \"lark\" (international) or \"feishu\" (China).", "default": "lark", }, + "redirect_url": map[string]any{ + "type": "string", + "description": "OAuth redirect URI registered in the Lark app. Leave empty to use the server default: {your-server}/api/auth/profile/oauth/lark/callback.", + }, }, "required": []any{"app_id", "app_secret", "brand"}, } @@ -69,6 +75,13 @@ func decodeConfig(raw map[string]any) (Config, error) { } cfg.Brand = s } + if v, ok := raw["redirect_url"]; ok { + s, ok := v.(string) + if !ok { + return Config{}, fmt.Errorf("redirect_url: must be a string") + } + cfg.RedirectURL = s + } return cfg, nil } From 7b7ac256ebf6ec6b1d02548fb8426dc97ac1f079 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 10:17:50 +0800 Subject: [PATCH 17/21] format --- internal/oauthcli/token_manager.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/oauthcli/token_manager.go b/internal/oauthcli/token_manager.go index 96c53616..8c404713 100644 --- a/internal/oauthcli/token_manager.go +++ b/internal/oauthcli/token_manager.go @@ -8,7 +8,6 @@ import ( "golang.org/x/oauth2" ) - // tokenExpirySafetyMargin is subtracted from expiry times to avoid using // tokens that are about to expire during a long-running operation. const tokenExpirySafetyMargin = 2 * time.Minute From e8db71a91852697f35e65ac1440fc00fe576a517 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Wed, 22 Apr 2026 11:57:06 +0800 Subject: [PATCH 18/21] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20improve=20skil?= =?UTF-8?q?ls=20UI=20and=20Lark=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace file tab bar with dropdown selector in skills drawer - Make custom agent skill rows clickable to open skills drawer - Switch builtin skill badges to open drawer (read-only) instead of toggle - Add openAgentSkill() for agent-scoped drawer navigation - Update Lark builddeps and sync_builtin for device-flow OAuth - Update docs for Feishu channel and agent templates Assisted-by: claude-code:claude-sonnet-4-6 --- docs/content/docs/channels/feishu.md | 3 +- docs/content/docs/channels/feishu.zh.md | 3 +- docs/content/docs/features/agent-templates.md | 16 +- .../docs/features/agent-templates.zh.md | 16 +- internal/admin/ui/pages/agents.templ | 1 + internal/admin/ui/pages/agents_form.templ | 13 +- internal/admin/ui/pages/agents_form_templ.go | 14 +- internal/admin/ui/pages/agents_templ.go | 4 + internal/admin/ui/skills_drawer.templ | 48 +++--- internal/admin/ui/skills_drawer_templ.go | 2 +- internal/admin/ui/static/js/pages/agents.js | 36 +++-- .../agent/runner/template/system_prompt.tmpl | 4 +- internal/builddeps/lark.go | 151 ++++++++++++++---- internal/builddeps/lark_test.go | 22 ++- internal/config/snapshot.go | 7 +- internal/skills/sync_builtin.go | 25 +++ internal/skills/sync_builtin_test.go | 39 +++++ plugins/tools/skills/prompt.go | 26 +-- plugins/tools/skills/prompt_test.go | 47 +----- 19 files changed, 308 insertions(+), 169 deletions(-) diff --git a/docs/content/docs/channels/feishu.md b/docs/content/docs/channels/feishu.md index bd31f2d3..2dd7c049 100644 --- a/docs/content/docs/channels/feishu.md +++ b/docs/content/docs/channels/feishu.md @@ -30,8 +30,7 @@ anna now ships a generated builtin `lark` system skill, and release builds embed Typical setup: ```bash -command -v lark-cli -npm install -g @larksuite/cli +command -v lark-cli || npm install -g @larksuite/cli lark-cli config init --new lark-cli auth login --recommend lark-cli auth status diff --git a/docs/content/docs/channels/feishu.zh.md b/docs/content/docs/channels/feishu.zh.md index 2c51c9e8..b336fce5 100644 --- a/docs/content/docs/channels/feishu.zh.md +++ b/docs/content/docs/channels/feishu.zh.md @@ -28,8 +28,7 @@ anna 现在会内置生成好的 `lark` system skill,发布构建也会自动 常见初始化流程: ```bash -command -v lark-cli -npm install -g @larksuite/cli +command -v lark-cli || npm install -g @larksuite/cli lark-cli config init --new lark-cli auth login --recommend lark-cli auth status diff --git a/docs/content/docs/features/agent-templates.md b/docs/content/docs/features/agent-templates.md index 0e3288c9..a6f27a4a 100644 --- a/docs/content/docs/features/agent-templates.md +++ b/docs/content/docs/features/agent-templates.md @@ -21,7 +21,7 @@ A template is a complete starting point for a new agent. When you click **Add ag - **Model** — provider/model pair the template recommends - **System prompt** — copied from the template's referenced soul -- **Enabled builtin skills** — shown as chips on the form; togglable before save +- **Builtin skill metadata** — retained for compatibility with older templates, but system-scope builtin skills are now always available to every agent User-supplied fields always win. You can edit every field on the form before saving, and after save the agent has no persistent link back to the template — upgrading a template does not touch existing agents. @@ -46,22 +46,22 @@ Sub-agent presets describe tool-restricted workers for the `agent` delegation to Shipped sub-agents: `coder`, `researcher`, `reviewer`, `writer`. -## Skills and per-agent enablement +## Skills -Every skill marked `scope='system'` is universal by design, which means naive growth of the builtin catalog would drop every skill into every agent's prompt — fast prompt bloat. +Every skill synced into the database with `scope='system'` is available to every agent automatically. -The fix: `settings_agents.enabled_builtin_skills` (JSON array of skill names). An agent's skill catalog in the prompt is: +An agent's skill catalog in the prompt is: ``` -{always-on builtins: anna} - ∪ {enabled_builtin_skills} +{all system-scope builtin skills} ∪ {agent-scope DB skills} ∪ {user-scope DB skills} + ∪ {project skills from .agents/skills} ``` -`anna` (the self-knowledge skill) is always on. Every other builtin skill must be opted in — either via the template you picked (which sets the list for you) or by toggling chips on the agent form. +The legacy `settings_agents.enabled_builtin_skills` field is still stored for backward compatibility with older templates and agent rows, but it no longer filters prompt visibility. -Shipped skills: `anna`, `code-review`, `docs-writing`, `implementation`, `research`, `task-planning`. +Shipped system skills live under `internal/resources/skills/system/` and are synced into `skills(scope='system')` on startup. Startup sync is authoritative: skills removed from the embedded system catalog are deleted from the database on the next sync. ## Adding a new builtin resource diff --git a/docs/content/docs/features/agent-templates.zh.md b/docs/content/docs/features/agent-templates.zh.md index c0445075..52e5e3d5 100644 --- a/docs/content/docs/features/agent-templates.zh.md +++ b/docs/content/docs/features/agent-templates.zh.md @@ -21,7 +21,7 @@ Anna 自带一套**内置资源目录**,让全新安装即开即用,无需 - **模型** — 模板推荐的 provider/model 组合 - **系统提示** — 复制自模板引用的 soul -- **已启用的内置技能** — 以芯片形式显示在表单上,保存前可自由切换 +- **内置技能元数据** — 为兼容旧模板而保留,但 `scope='system'` 的内置技能现已自动对所有 agent 可用 用户手动输入始终优先。所有字段在保存前都可以编辑;保存之后 agent 与模板没有任何持久关联 — 更新模板不会影响已有 agent。 @@ -46,22 +46,22 @@ Sub-agent 预设定义了 `agent` 委派工具使用的受限工作者(调研 附带的 sub-agent:`coder`、`researcher`、`reviewer`、`writer`。 -## Skill 与按 agent 开关 +## Skill -每个 `scope='system'` 的技能天生对所有 agent 可见,因此朴素地增长内置技能目录会把所有技能同时塞进每个 agent 的提示 — 提示体积会迅速膨胀。 +所有同步到数据库且 `scope='system'` 的技能,都会自动对所有 agent 可用。 -解决方式:`settings_agents.enabled_builtin_skills`(JSON 字符串数组)。agent 看到的技能目录为: +agent 在提示里看到的技能目录为: ``` -{常驻内置:anna} - ∪ {enabled_builtin_skills 中列出的} +{全部 system 范围内置技能} ∪ {agent 范围的数据库技能} ∪ {user 范围的数据库技能} + ∪ {来自 .agents/skills 的 project 技能} ``` -`anna`(自我知识技能)永远启用。其他内置技能必须显式开启 — 通过你选择的模板(模板会替你设好)或手动切换表单上的芯片。 +历史遗留的 `settings_agents.enabled_builtin_skills` 字段仍会保留,以兼容旧模板和旧 agent 行,但它已不再参与提示可见性的过滤。 -附带的技能:`anna`、`code-review`、`docs-writing`、`implementation`、`research`、`task-planning`。 +附带的 system skill 位于 `internal/resources/skills/system/`,启动时会同步到 `skills(scope='system')`。启动同步是权威来源:如果某个嵌入式 system skill 已从目录中移除,那么下一次同步时也会从数据库中删除。 ## 新增一个内置资源 diff --git a/internal/admin/ui/pages/agents.templ b/internal/admin/ui/pages/agents.templ index 17d769e5..b0f1a858 100644 --- a/internal/admin/ui/pages/agents.templ +++ b/internal/admin/ui/pages/agents.templ @@ -11,6 +11,7 @@ templ AgentsPage() { @agentTemplateModal() @agentSkillInstallModal() @agentConfirmDialog() + @ui.SkillsDrawer()
} diff --git a/internal/admin/ui/pages/agents_form.templ b/internal/admin/ui/pages/agents_form.templ index e5be7e90..d3a2e6b3 100644 --- a/internal/admin/ui/pages/agents_form.templ +++ b/internal/admin/ui/pages/agents_form.templ @@ -148,14 +148,13 @@ templ agentSkillsTab() {

Builtin skills

-

Anna's self-knowledge is always on. Toggle extras for this agent.

+

System-scope builtin skills are always available to every agent.