diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 3b240b1765..993dbe179b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -49,6 +49,9 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra. Short: "Device Flow authorization login", Long: `Device Flow authorization login. +With no --scope/--domain/--recommend flag, this requests scopes for all known +business domains (equivalent to --domain all); pass --domain or --scope to narrow it. + For AI agents: this command blocks until the user completes authorization in the browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json, send the verification URL (or QR code) to the user as your final message, end the turn, then @@ -71,7 +74,7 @@ to generate QR codes (supports ASCII and PNG formats).`, cmdutil.SetRisk(cmd, "write") cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend") - cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") + cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request scopes for all known domains (equivalent to --domain all)") var helpBrand core.LarkBrand if f != nil && f.Config != nil { if cfg, err := f.Config(); err == nil && cfg != nil { @@ -144,33 +147,6 @@ func authLoginRun(opts *LoginOptions) error { } selectedDomains := opts.Domains - scopeLevel := "" // "common" or "all" (from interactive mode) - - // Expand --domain all to all available domains (from_meta projects + shortcut services) - for _, d := range selectedDomains { - if strings.EqualFold(d, "all") { - selectedDomains = sortedKnownDomains(config.Brand) - break - } - } - - // Validate domain names and suggest corrections for unknown ones - if len(selectedDomains) > 0 { - knownDomains := allKnownDomains(config.Brand) - for _, d := range selectedDomains { - if !knownDomains[d] { - if suggestion := suggestDomain(d, knownDomains); suggestion != "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain") - } - available := make([]string, 0, len(knownDomains)) - for k := range knownDomains { - available = append(available, k) - } - sort.Strings(available) - return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain") - } - } - } hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0 @@ -178,30 +154,51 @@ func authLoginRun(opts *LoginOptions) error { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude") } - if !hasAnyOption { - if !opts.JSON && f.IOStreams.IsTerminal { - result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand) - if err != nil { - return err + // scopeOnly is the one path that must never touch the domain catalog + // (remote or local): --scope given alone, with neither --domain nor + // --recommend. Every other path — including bare `auth login`, now that + // the interactive picker is gone — needs the legal domain set to resolve + // scopes. + scopeOnly := opts.Scope != "" && !opts.Recommend && len(selectedDomains) == 0 + + var remote map[string][]string + var remoteOK bool + + if !scopeOnly { + // Pull the remote scopes.json once for this login (not cached); any + // read failure (network/timeout/non-2xx/malformed) silently falls back + // to the local full computation — no warning, no telemetry. + remote, remoteOK = larkauth.FetchRemoteScopes(config.Brand) + legalDomains, allLegalDomains := legalDomainsFor(remote, remoteOK, config.Brand) + + // Expand --domain all against the resolved legal domain set. + for _, d := range selectedDomains { + if strings.EqualFold(d, "all") { + selectedDomains = allLegalDomains + break } - if result == nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected") + } + + if len(selectedDomains) > 0 { + // Validate explicitly-supplied domain names and suggest corrections. + for _, d := range selectedDomains { + if !legalDomains[d] { + if suggestion := suggestDomain(d, legalDomains); suggestion != "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain") + } + available := make([]string, 0, len(legalDomains)) + for k := range legalDomains { + available = append(available, k) + } + sort.Strings(available) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain") + } } - selectedDomains = result.Domains - scopeLevel = result.ScopeLevel } else { - log(msg.HintHeader) - log("Common options:") - log(msg.HintCommon1) - log(msg.HintCommon2) - log(msg.HintCommon3) - log(msg.HintCommon4) - log("") - log("View all options:") - log(msg.HintFooter) - log("") - log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.") - return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope") + // Bare `auth login` and `--recommend` without `--domain` both span + // the full legal domain set now that the interactive picker and + // the local auto-approve filter are gone (--recommend ≡ --domain all). + selectedDomains = allLegalDomains } } @@ -215,19 +212,8 @@ func authLoginRun(opts *LoginOptions) error { // --scope, --domain, and --recommend combine additively so callers can, // for example, request all `docs` scopes plus a few specific `drive` // scopes in a single command. - if len(selectedDomains) > 0 || opts.Recommend { - var candidateScopes []string - if len(selectedDomains) > 0 { - candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand) - } else { - // --recommend without --domain: all domains - candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand) - } - - // Filter to auto-approve scopes if --recommend or interactive "common" - if opts.Recommend || scopeLevel == "common" { - candidateScopes = registry.FilterAutoApproveScopes(candidateScopes) - } + if len(selectedDomains) > 0 { + candidateScopes := resolveScopesForDomains(selectedDomains, remote, remoteOK, config.Brand) if len(candidateScopes) == 0 && opts.Scope == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options") @@ -358,6 +344,7 @@ func authLoginRun(opts *LoginOptions) error { } scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope) + scopeSummary.StatusMessage = result.Token.StatusMessage // Step 7: Store token now := time.Now().UnixMilli() @@ -441,6 +428,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo } scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope) + scopeSummary.StatusMessage = result.Token.StatusMessage // Store token now := time.Now().UnixMilli() @@ -550,6 +538,30 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB return result } +// resolveScopesForDomains resolves the scope set for the given domains. When +// the remote scopes.json is available it takes the union of each domain's +// user_scopes from the remote result (remote is authoritative, including +// domains this CLI build doesn't know about locally); otherwise it falls back +// to the local synthesis via collectScopesForDomains. Always returns a +// deduplicated, alphabetically sorted slice. +func resolveScopesForDomains(domains []string, remote map[string][]string, remoteOK bool, brand core.LarkBrand) []string { + if remoteOK { + set := make(map[string]bool) + for _, d := range domains { + for _, s := range remote[d] { + set[s] = true + } + } + out := make([]string, 0, len(set)) + for s := range set { + out = append(out, s) + } + sort.Strings(out) + return out + } + return collectScopesForDomains(domains, "user", brand) +} + // allKnownDomains returns all valid auth domain names (from_meta projects + // shortcut services), excluding domains that have auth_domain set (they are // folded into their parent domain). @@ -582,6 +594,25 @@ func sortedKnownDomains(brand core.LarkBrand) []string { return domains } +// legalDomainsFor returns the authoritative domain set for this login: the +// remote scopes.json keys when available (a remote-listed domain unknown to +// this CLI build is still legal), otherwise the local known-domain set. +// Returns both a membership set (for --domain validation) and a sorted slice +// (for `all` expansion and the bare-login/--recommend-without-domain default). +func legalDomainsFor(remote map[string][]string, remoteOK bool, brand core.LarkBrand) (map[string]bool, []string) { + if remoteOK { + set := make(map[string]bool, len(remote)) + sorted := make([]string, 0, len(remote)) + for d := range remote { + set[d] = true + sorted = append(sorted, d) + } + sort.Strings(sorted) + return set, sorted + } + return allKnownDomains(brand), sortedKnownDomains(brand) +} + // shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot"). // Empty AuthTypes defaults to ["user"]. func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool { diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go deleted file mode 100644 index 70e01065cb..0000000000 --- a/cmd/auth/login_interactive.go +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package auth - -import ( - "fmt" - "sort" - "strings" - - "github.com/charmbracelet/huh" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/registry" - "github.com/larksuite/cli/shortcuts" -) - -// domainMeta describes a domain for the interactive selector. -type domainMeta struct { - Name string - Title string - Description string -} - -// interactiveResult holds the user's selections from the interactive form. -type interactiveResult struct { - Domains []string - ScopeLevel string // "common" or "all" -} - -// getDomainMetadata returns metadata for all known domains, sorted by name. -func getDomainMetadata(lang string) []domainMeta { - seen := make(map[string]bool) - var domains []domainMeta - - // 1. Domains from from_meta projects (skip domains with auth_domain) - for _, project := range registry.ListFromMetaProjects() { - if registry.HasAuthDomain(project) { - seen[project] = true - continue - } - dm := buildDomainMeta(project, lang) - domains = append(domains, dm) - seen[project] = true - } - - // 2. Shortcut-only domains - shortcutOnlyNames := getShortcutOnlyDomainNames() - for _, name := range shortcutOnlyNames { - if !seen[name] { - dm := buildDomainMeta(name, lang) - domains = append(domains, dm) - seen[name] = true - } - } - - // 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains - // (skip domains with auth_domain — they are folded into their parent) - shortcutOnlySet := make(map[string]bool) - for _, n := range shortcutOnlyNames { - shortcutOnlySet[n] = true - } - for _, sc := range shortcuts.AllShortcuts() { - if !seen[sc.Service] { - if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) { - dm := buildDomainMeta(sc.Service, lang) - domains = append(domains, dm) - } - seen[sc.Service] = true - } - } - - sort.Slice(domains, func(i, j int) bool { - return domains[i].Name < domains[j].Name - }) - return domains -} - -// buildDomainMeta constructs a domainMeta for a given service name and language. -// It reads from the service_descriptions.json config first, falling back to -// from_meta spec fields if not found. -func buildDomainMeta(name, lang string) domainMeta { - title := registry.GetServiceTitle(name, lang) - desc := registry.GetServiceDetailDescription(name, lang) - if title != "" || desc != "" { - return domainMeta{ - Name: name, - Title: title, - Description: desc, - } - } - // Fallback: read from the typed service spec (legacy) - dm := domainMeta{Name: name} - if svc, ok := registry.ServiceTyped(name); ok { - dm.Title = svc.Title - dm.Description = svc.Description - } - return dm -} - -// runInteractiveLogin shows an interactive TUI form for domain and permission selection. -func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) { - allDomains := getDomainMetadata(lang) - - // Build multi-select options - options := make([]huh.Option[string], len(allDomains)) - for i, dm := range allDomains { - var label string - switch { - case dm.Title != "" && dm.Description != "": - label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description) - case dm.Title != "": - label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title) - default: - label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description) - } - options[i] = huh.NewOption(label, dm.Name) - } - - var selectedDomains []string - var permLevel string - - // Phase 1a: domain selection - // Phase 1b: permission level (shown after domain selection completes) - form1 := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title(msg.SelectDomains). - Description(msg.DomainHint). - Options(options...). - Value(&selectedDomains). - Validate(func(s []string) error { - if len(s) == 0 { - return fmt.Errorf(msg.ErrNoDomain) - } - return nil - }), - ), - huh.NewGroup( - huh.NewSelect[string](). - Title(msg.PermLevel). - Options( - huh.NewOption(msg.PermCommon, "common"), - huh.NewOption(msg.PermAll, "all"), - ). - Value(&permLevel), - ), - ).WithTheme(cmdutil.ThemeFeishu()) - - if err := form1.Run(); err != nil { - if err == huh.ErrUserAborted { - return nil, output.ErrBare(1) - } - return nil, err - } - - if len(selectedDomains) == 0 { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain") - } - - // Compute scope summary - scopes := collectScopesForDomains(selectedDomains, "user", brand) - if permLevel == "common" { - scopes = registry.FilterAutoApproveScopes(scopes) - } - - // Print summary - permLabel := msg.PermAllLabel - if permLevel == "common" { - permLabel = msg.PermCommonLabel - } - fmt.Fprintf(ios.ErrOut, msg.Summary) - fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", ")) - fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel) - scopePreview := strings.Join(scopes, ", ") - if len(scopePreview) > 80 { - scopePreview = strings.Join(scopes[:3], ", ") + ", ..." - } - fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview) - - return &interactiveResult{ - Domains: selectedDomains, - ScopeLevel: permLevel, - }, nil -} diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 2dee8992f7..b0004c83dd 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -6,114 +6,49 @@ package auth import "github.com/larksuite/cli/internal/i18n" type loginMsg struct { - // Interactive UI (login_interactive.go) - SelectDomains string - DomainHint string - PermLevel string - PermCommon string - PermAll string - Summary string - SummaryDomains string - SummaryPerm string - SummaryScopes string - PermAllLabel string - PermCommonLabel string - ErrNoDomain string - ConfirmAuth string - // Non-interactive prompts (login.go) - OpenURL string - WaitingAuth string - AgentTimeoutHint string - AuthSuccess string - LoginSuccess string - AuthorizedUser string - ScopeMismatch string - ScopeHint string - RequestedScopes string - NewlyGrantedScopes string - NoScopes string - StatusHint string - - // Non-interactive hint (no flags) - HintHeader string - HintCommon1 string - HintCommon2 string - HintCommon3 string - HintCommon4 string - HintFooter string + OpenURL string + WaitingAuth string + AgentTimeoutHint string + AuthSuccess string + LoginSuccess string + ScopeMismatch string + ScopeHint string + GrantedScopes string + NotGrantedScopes string + AuthDetails string + ScopeSeparator string + NoScopes string } var loginMsgZh = &loginMsg{ - SelectDomains: "选择要授权的业务域", - DomainHint: "空格=选择, 回车=确认", - PermLevel: "权限类型", - PermCommon: "常用权限", - PermAll: "全部权限", - Summary: "\n摘要:\n", - SummaryDomains: " 域: %s\n", - SummaryPerm: " 权限: %s\n", - SummaryScopes: " Scopes (%d): %s\n\n", - PermAllLabel: "全部权限", - PermCommonLabel: "常用权限", - ErrNoDomain: "请至少选择一个业务域", - ConfirmAuth: "确认授权?", - - OpenURL: "在浏览器中打开以下链接进行认证:\n\n", - WaitingAuth: "等待用户授权...", - AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code \" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL,再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点)。", - AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...", - LoginSuccess: "授权成功! 用户: %s (%s)", - AuthorizedUser: "当前授权账号: %s (%s)", - ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s", - ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", - RequestedScopes: " 本次请求 scopes: %s\n", - NewlyGrantedScopes: " 本次新授予 scopes: %s\n", - NoScopes: "(空)", - StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", - - HintHeader: "请指定要授权的权限:\n", - HintCommon1: " --recommend 授权推荐权限", - HintCommon2: " --domain all 授权所有已知域的权限", - HintCommon3: " --domain calendar,task 授权日历和任务域的权限", - HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限", - HintFooter: " lark-cli auth login --help", + OpenURL: "在浏览器中打开以下链接进行认证:\n\n", + WaitingAuth: "等待用户授权...", + AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code \" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL,再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点)。", + AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...", + LoginSuccess: "登录成功! 用户: %s (%s)", + ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s", + ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + GrantedScopes: "本次已成功授权:", + NotGrantedScopes: "以下是本次未授予的权限:", + AuthDetails: "本次授权结果详情:", + ScopeSeparator: "、", + NoScopes: "(空)", } var loginMsgEn = &loginMsg{ - SelectDomains: "Select domains to authorize", - DomainHint: "Space=toggle, Enter=confirm", - PermLevel: "Permission level", - PermCommon: "Common scopes", - PermAll: "All scopes", - Summary: "\nSummary:\n", - SummaryDomains: " Domains: %s\n", - SummaryPerm: " Level: %s\n", - SummaryScopes: " Scopes (%d): %s\n\n", - PermAllLabel: "All scopes", - PermCommonLabel: "Common scopes", - ErrNoDomain: "please select at least one domain", - ConfirmAuth: "Confirm authorization?", - - OpenURL: "Open this URL in your browser to authenticate:\n\n", - WaitingAuth: "Waiting for user authorization...", - AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code \" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.", - AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...", - LoginSuccess: "Authorization successful! User: %s (%s)", - AuthorizedUser: "Authorized account: %s (%s)", - ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s", - ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.", - RequestedScopes: " Requested scopes: %s\n", - NewlyGrantedScopes: " Newly granted scopes: %s\n", - NoScopes: "(none)", - StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.", - - HintHeader: "Please specify the scopes to authorize:\n", - HintCommon1: " --recommend authorize recommended scopes", - HintCommon2: " --domain all authorize all known domain scopes", - HintCommon3: " --domain calendar,task authorize calendar and task scopes", - HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes", - HintFooter: " lark-cli auth login --help", + OpenURL: "Open this URL in your browser to authenticate:\n\n", + WaitingAuth: "Waiting for user authorization...", + AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code \" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.", + AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...", + LoginSuccess: "Authorization successful! User: %s (%s)", + ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s", + ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.", + GrantedScopes: "- Successfully authorized in this request:", + NotGrantedScopes: "- Scopes not granted in this request:", + AuthDetails: "- Authorization details:", + ScopeSeparator: ", ", + NoScopes: "(none)", } // getLoginMsg returns the login message bundle for the given language. @@ -123,10 +58,3 @@ func getLoginMsg(lang i18n.Lang) *loginMsg { } return loginMsgZh } - -// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts -// (not backed by from_meta service specs). Descriptions are now centralized in -// service_descriptions.json. -func getShortcutOnlyDomainNames() []string { - return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"} -} diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go index a5c7f936ca..45b121b424 100644 --- a/cmd/auth/login_messages_test.go +++ b/cmd/auth/login_messages_test.go @@ -17,8 +17,11 @@ func TestGetLoginMsg_Zh(t *testing.T) { if msg != loginMsgZh { t.Error("expected zh message set") } - if msg.SelectDomains != "选择要授权的业务域" { - t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains) + if msg.OpenURL != "在浏览器中打开以下链接进行认证:\n\n" { + t.Errorf("unexpected OpenURL: %s", msg.OpenURL) + } + if msg.LoginSuccess != "登录成功! 用户: %s (%s)" { + t.Errorf("unexpected LoginSuccess: %s", msg.LoginSuccess) } } @@ -27,8 +30,8 @@ func TestGetLoginMsg_En(t *testing.T) { if msg != loginMsgEn { t.Error("expected en message set") } - if msg.SelectDomains != "Select domains to authorize" { - t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains) + if msg.OpenURL != "Open this URL in your browser to authenticate:\n\n" { + t.Errorf("unexpected OpenURL: %s", msg.OpenURL) } } @@ -72,29 +75,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) { t.Errorf("%s LoginSuccess has no format verb", lang) } - // AuthorizedUser should contain two %s placeholders (userName, openId) - got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123") - if got == msg.AuthorizedUser { - t.Errorf("%s AuthorizedUser has no format verb", lang) - } - - // SummaryDomains should contain %s - got = fmt.Sprintf(msg.SummaryDomains, "calendar, task") - if got == msg.SummaryDomains { - t.Errorf("%s SummaryDomains has no format verb", lang) - } - - // SummaryPerm should contain %s - got = fmt.Sprintf(msg.SummaryPerm, "all") - if got == msg.SummaryPerm { - t.Errorf("%s SummaryPerm has no format verb", lang) - } - - // SummaryScopes should contain %d and %s - got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c") - if got == msg.SummaryScopes { - t.Errorf("%s SummaryScopes has no format verb", lang) - } } } diff --git a/cmd/auth/login_result.go b/cmd/auth/login_result.go index 84eccc97d0..860be5fe4d 100644 --- a/cmd/auth/login_result.go +++ b/cmd/auth/login_result.go @@ -20,6 +20,7 @@ type loginScopeSummary struct { AlreadyGranted []string Granted []string Missing []string + StatusMessage string } type loginScopeIssue struct { @@ -114,11 +115,27 @@ func uniqueScopeList(scope string) []string { // formatScopeList joins scopes for display and falls back to the provided empty // label when the input slice is empty. -func formatScopeList(scopes []string, empty string) string { +func formatScopeList(scopes []string, empty, separator string) string { if len(scopes) == 0 { return empty } - return strings.Join(scopes, " ") + return strings.Join(scopes, separator) +} + +// grantedRequestedScopes returns requested scopes that are not in the missing +// set, preserving the order of the authorization request. +func grantedRequestedScopes(summary *loginScopeSummary) []string { + missing := make(map[string]bool, len(summary.Missing)) + for _, scope := range summary.Missing { + missing[scope] = true + } + granted := make([]string, 0, len(summary.Requested)) + for _, scope := range summary.Requested { + if !missing[scope] { + granted = append(granted, scope) + } + } + return granted } // emptyIfNil normalizes nil slices to empty slices for stable JSON output. @@ -129,14 +146,47 @@ func emptyIfNil(s []string) []string { return s } -// writeLoginScopeBreakdown renders the requested/newly granted scope -// breakdown to stderr. +// writeLoginScopeBreakdown renders the compact granted/not-granted result. func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) { if summary == nil { summary = &loginScopeSummary{} } - fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes)) - fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes)) + fmt.Fprintln(errOut.ErrOut) + fmt.Fprintln(errOut.ErrOut, msg.GrantedScopes) + fmt.Fprintf(errOut.ErrOut, " %s\n", formatScopeList(grantedRequestedScopes(summary), msg.NoScopes, msg.ScopeSeparator)) + if summary.StatusMessage != "" { + fmt.Fprintln(errOut.ErrOut) + heading := msg.NotGrantedScopes + if len(summary.Missing) == 0 { + heading = msg.AuthDetails + } + fmt.Fprintln(errOut.ErrOut, heading) + writeLoginStatusMessage(errOut, summary.StatusMessage) + return + } + if len(summary.Missing) == 0 { + return + } + fmt.Fprintln(errOut.ErrOut) + fmt.Fprintln(errOut.ErrOut, msg.NotGrantedScopes) + fmt.Fprintf(errOut.ErrOut, " %s\n", formatScopeList(summary.Missing, msg.NoScopes, msg.ScopeSeparator)) +} + +// writeLoginStatusMessage appends the server-rendered authorization result +// without interpreting or rewriting its contents. +func writeLoginStatusMessage(errOut *cmdutil.IOStreams, statusMessage string) { + if statusMessage == "" { + return + } + for _, line := range strings.SplitAfter(statusMessage, "\n") { + if line == "" { + continue + } + fmt.Fprint(errOut.ErrOut, " ", line) + } + if !strings.HasSuffix(statusMessage, "\n") { + fmt.Fprintln(errOut.ErrOut) + } } // writeLoginSuccess emits the successful login payload in either JSON or text @@ -154,9 +204,6 @@ func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, op fmt.Fprintln(f.IOStreams.ErrOut) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) writeLoginScopeBreakdown(f.IOStreams, msg, summary) - if len(summary.Missing) == 0 && msg.StatusHint != "" { - fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint) - } } // handleLoginScopeIssue prints or returns a structured missing-scope result @@ -182,15 +229,12 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory fmt.Fprintln(f.IOStreams.ErrOut) if loginSucceeded { - fmt.Fprintln(f.IOStreams.ErrOut, issue.Message) - if msg.AuthorizedUser != "" { - fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId)) - } + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) } else { fmt.Fprintln(f.IOStreams.ErrOut, issue.Message) } writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary) - if issue.Hint != "" { + if !loginSucceeded && issue.Hint != "" { fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint) } return output.ErrBare(output.ExitAuth) @@ -214,10 +258,13 @@ func authorizationCompletePayload(openId, userName string, summary *loginScopeSu "granted": emptyIfNil(summary.Granted), } if issue != nil { + hint := summary.StatusMessage + if hint == "" { + hint = issue.Message + } payload["warning"] = map[string]interface{}{ - "type": "missing_scope", - "message": issue.Message, - "hint": issue.Hint, + "type": "missing_scope", + "hint": hint, } } return payload diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index f2aa3389a9..1cc44a7131 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -9,7 +9,7 @@ import ( "errors" "io" "net/http" - "slices" + "reflect" "sort" "strings" "testing" @@ -202,25 +202,6 @@ func TestSortedKnownDomains(t *testing.T) { } } -func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { - for _, name := range getShortcutOnlyDomainNames() { - zhDesc := registry.GetServiceDescription(name, "zh") - enDesc := registry.GetServiceDescription(name, "en") - if zhDesc == "" { - t.Errorf("missing zh description for shortcut-only domain %q", name) - } - if enDesc == "" { - t.Errorf("missing en description for shortcut-only domain %q", name) - } - } -} - -func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) { - if !slices.Contains(getShortcutOnlyDomainNames(), "note") { - t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read") - } -} - func TestCollectScopesForDomains(t *testing.T) { projects := registry.ListFromMetaProjects() if len(projects) == 0 { @@ -260,75 +241,82 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) { } } -func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { - domains := getDomainMetadata("zh") - nameSet := make(map[string]bool) - for _, dm := range domains { - nameSet[dm.Name] = true +func TestResolveScopesForDomains_RemoteUsed(t *testing.T) { + remote := map[string][]string{ + "im": {"im:message:send", "im:chat:read"}, + "docs": {"docs:doc:read"}, } - - // from_meta projects must be present - for _, p := range registry.ListFromMetaProjects() { - if !nameSet[p] { - t.Errorf("from_meta project %q missing from getDomainMetadata", p) - } + got := resolveScopesForDomains([]string{"im"}, remote, true, core.BrandFeishu) + want := []string{"im:chat:read", "im:message:send"} // deduped, ascending by sort.Strings + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) } } -func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) { - domains := getDomainMetadata("zh") - nameSet := make(map[string]bool) - for _, dm := range domains { - nameSet[dm.Name] = true +func TestResolveScopesForDomains_UnionAcrossDomains(t *testing.T) { + remote := map[string][]string{ + "im": {"im:message:send"}, + "docs": {"docs:doc:read"}, } - - for _, name := range getShortcutOnlyDomainNames() { - if !nameSet[name] { - t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name) - } + got := resolveScopesForDomains([]string{"im", "docs"}, remote, true, core.BrandFeishu) + want := []string{"docs:doc:read", "im:message:send"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) } } -func TestGetDomainMetadata_Sorted(t *testing.T) { - domains := getDomainMetadata("zh") - for i := 1; i < len(domains); i++ { - if domains[i].Name < domains[i-1].Name { - t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name) - } +func TestResolveScopesForDomains_FallbackToLocal(t *testing.T) { + // remoteOK=false -> falls back to local collectScopesForDomains; im must yield non-empty local scopes + got := resolveScopesForDomains([]string{"im"}, nil, false, core.BrandFeishu) + if len(got) == 0 { + t.Fatal("fallback should return non-empty local scopes for im") } } -func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) { - domains := getDomainMetadata("zh") - for _, dm := range domains { - if dm.Title == "" { - t.Errorf("domain %q has empty Title", dm.Name) +func TestLegalDomainsFor_RemoteUsed(t *testing.T) { + // includes "newbiz", a domain unknown to this CLI build, verifying a remote-listed domain is still legal + remote := map[string][]string{ + "im": {"im:message:send"}, + "docs": {"docs:doc:read"}, + "newbiz": {"newbiz:thing:read"}, + } + set, sorted := legalDomainsFor(remote, true, core.BrandFeishu) + wantSorted := []string{"docs", "im", "newbiz"} // remote keys, ascending by sort.Strings + if !reflect.DeepEqual(sorted, wantSorted) { + t.Fatalf("sorted = %v, want %v", sorted, wantSorted) + } + if len(set) != len(wantSorted) { + t.Fatalf("set size = %d, want %d", len(set), len(wantSorted)) + } + for _, d := range wantSorted { + if !set[d] { + t.Errorf("set missing domain %q", d) } } + if !set["newbiz"] { + t.Error("remote-listed domain unknown to this build should still be legal") + } } -func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { - f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, - }) - // TestFactory has IsTerminal=false by default - opts := &LoginOptions{Factory: f, Ctx: context.Background()} - err := authLoginRun(opts) - if err == nil { - t.Fatal("expected error for non-terminal without flags") +func TestLegalDomainsFor_FallbackToLocal(t *testing.T) { + // remoteOK=false -> falls back to local allKnownDomains/sortedKnownDomains + set, sorted := legalDomainsFor(nil, false, core.BrandFeishu) + if len(sorted) == 0 { + t.Fatal("fallback should return non-empty local domain slice") } - // Should mention specifying scopes - msg := err.Error() - if !strings.Contains(msg, "scopes") { - t.Errorf("expected error to mention scopes, got: %s", msg) + // set and sorted are two views of the same local domain set and must correspond + if len(set) != len(sorted) { + t.Fatalf("set size %d != sorted size %d", len(set), len(sorted)) } - // Stderr should explain the split-flow path for non-streaming agents. - stderrStr := stderr.String() - for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} { - if !strings.Contains(stderrStr, want) { - t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr) + for _, d := range sorted { + if !set[d] { + t.Errorf("set missing local domain %q", d) } } + // fallback adopts the local sort order directly, which must equal sortedKnownDomains + if want := sortedKnownDomains(core.BrandFeishu); !reflect.DeepEqual(sorted, want) { + t.Fatalf("sorted = %v, want sortedKnownDomains %v", sorted, want) + } } func TestEnsureRequestedScopesGranted(t *testing.T) { @@ -371,12 +359,15 @@ func TestBuildLoginScopeSummary(t *testing.T) { func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) - writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{ + summary := &loginScopeSummary{ Requested: []string{"im:message:send", "im:message:reply"}, NewlyGranted: []string{"im:message:send"}, AlreadyGranted: []string{"im:message:reply"}, Granted: []string{"im:message:send", "im:message:reply"}, - }) + StatusMessage: "[用户跳过,可重试] 用户未勾选:calendar:calendar:update\n" + + "应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核:im:message", + } + writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", summary) var data map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { @@ -394,19 +385,25 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) { if len(data["already_granted"].([]interface{})) != 1 { t.Fatalf("already_granted = %#v", data["already_granted"]) } + if _, ok := data["status_message"]; ok { + t.Fatalf("status_message should not be exposed at the top level: %#v", data) + } } func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) - err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{ + issue := &loginScopeIssue{ Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send", Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", Summary: &loginScopeSummary{ - Requested: []string{"im:message:send"}, + Requested: []string{"im:message:send", "im:message:reply"}, Missing: []string{"im:message:send"}, - Granted: []string{"base:app:copy"}, + Granted: []string{"im:message:reply", "base:app:copy"}, + StatusMessage: "[用户跳过,可重试] 用户未勾选:calendar:calendar:update\n" + + "应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核:im:message", }, - }, "ou_user", "tester") + } + err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, issue, "ou_user", "tester") if err == nil { t.Fatal("expected error, got nil") } @@ -415,40 +412,53 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { } got := stderr.String() for _, want := range []string{ - "授权结果异常: 以下请求 scopes 未被授予: im:message:send", - "当前授权账号: tester (ou_user)", - "本次请求 scopes: im:message:send", - "本次新授予 scopes: (空)", - "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试", - "scope 被禁用", - "lark-cli auth status", + "OK: 登录成功! 用户: tester (ou_user)", + "OK: 登录成功! 用户: tester (ou_user)\n\n" + + "本次已成功授权:\n" + + " im:message:reply\n\n" + + "以下是本次未授予的权限:\n" + + " [用户跳过,可重试] 用户未勾选:calendar:calendar:update\n" + + " 应用身份\n" + + " [待审核,通过后自动生效] 以下权限正在等待管理员审核:im:message", } { if !strings.Contains(got, want) { t.Fatalf("stderr missing %q, got:\n%s", want, got) } } - if strings.Contains(got, "最终已授权 scopes:") { - t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got) + successPos := strings.Index(got, "OK: 登录成功! 用户: tester (ou_user)") + scopePos := strings.Index(got, "本次已成功授权:\n im:message:reply") + missingPos := strings.Index(got, "以下是本次未授予的权限:") + statusPos := strings.Index(got, " [用户跳过,可重试]") + if successPos < 0 || scopePos <= successPos || missingPos <= scopePos || statusPos <= missingPos { + t.Fatalf("login result placement is wrong, got:\n%s", got) } - if strings.Contains(got, "授权成功") { - t.Fatalf("stderr should not contain success wording, got:\n%s", got) - } - if strings.Contains(got, "本次未授予 scopes:") { - t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got) + for _, unwanted := range []string{ + issue.Message, + "本次请求 scopes:", + "本次新授予 scopes:", + issue.Hint, + "当前授权账号:", + } { + if strings.Contains(got, unwanted) { + t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got) + } } } func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) - err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{ + issue := &loginScopeIssue{ Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send", Hint: "Granted scopes: base:app:copy. Check app scopes.", Summary: &loginScopeSummary{ Requested: []string{"im:message:send"}, Missing: []string{"im:message:send"}, Granted: []string{"base:app:copy"}, + StatusMessage: "[用户跳过,可重试] 用户未勾选:calendar:calendar:update\n" + + "应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核:im:message", }, - }, "ou_user", "tester") + } + err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, issue, "ou_user", "tester") if err == nil { t.Fatal("expected error, got nil") } @@ -473,6 +483,36 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { if warning["type"] != "missing_scope" { t.Fatalf("warning.type = %v", warning["type"]) } + if _, ok := warning["message"]; ok { + t.Fatalf("warning.message should not be exposed: %#v", warning) + } + if warning["hint"] != issue.Summary.StatusMessage { + t.Fatalf("warning.hint = %v, want %q", warning["hint"], issue.Summary.StatusMessage) + } + if _, ok := data["status_message"]; ok { + t.Fatalf("status_message should not be exposed at the top level: %#v", data) + } +} + +func TestAuthorizationCompletePayload_EmptyStatusMessageFallsBackToIssueMessage(t *testing.T) { + issue := &loginScopeIssue{ + Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send", + Summary: &loginScopeSummary{ + Missing: []string{"im:message:send"}, + }, + } + + payload := authorizationCompletePayload("ou_user", "tester", issue.Summary, issue) + warning, ok := payload["warning"].(map[string]interface{}) + if !ok { + t.Fatalf("warning = %#v", payload["warning"]) + } + if warning["hint"] != issue.Message { + t.Fatalf("warning.hint = %v, want %q", warning["hint"], issue.Message) + } + if _, ok := warning["message"]; ok { + t.Fatalf("warning.message should not be exposed: %#v", warning) + } } func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) { @@ -495,6 +535,63 @@ func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) { t.Fatalf("%s = %#v, want JSON array", k, v) } } + if _, ok := data["status_message"]; ok { + t.Fatalf("status_message should not be exposed at the top level: %#v", data) + } +} + +func TestWriteLoginSuccess_TextStatusMessageWithoutMissingScopesUsesDetailsHeading(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + statusMessage := "[用户跳过,可重试] 用户未勾选:calendar:calendar:update\n" + + "应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核:im:message" + + writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", &loginScopeSummary{ + Requested: []string{"im:message:send"}, + NewlyGranted: []string{"im:message:send"}, + Granted: []string{"im:message:send"}, + StatusMessage: statusMessage, + }) + + got := stderr.String() + wantBlock := "本次已成功授权:\n" + + " im:message:send\n\n" + + "本次授权结果详情:\n" + + " [用户跳过,可重试] 用户未勾选:calendar:calendar:update\n" + + " 应用身份\n" + + " [待审核,通过后自动生效] 以下权限正在等待管理员审核:im:message" + if !strings.Contains(got, wantBlock) { + t.Fatalf("stderr missing formatted authorization block %q, got:\n%s", wantBlock, got) + } + scopePos := strings.Index(got, "本次已成功授权:\n im:message:send") + detailsPos := strings.Index(got, "本次授权结果详情:") + statusPos := strings.Index(got, " [用户跳过,可重试]") + if scopePos < 0 || detailsPos <= scopePos || statusPos <= detailsPos { + t.Fatalf("status_message placement is wrong, got:\n%s", got) + } + if strings.Contains(got, "以下是本次未授予的权限:") { + t.Fatalf("stderr should not label status_message as not granted when no scopes are missing, got:\n%s", got) + } + if strings.Contains(got, "可执行 `lark-cli auth status`") { + t.Fatalf("stderr should not contain the hidden status hint, got:\n%s", got) + } +} + +func TestWriteLoginSuccess_TextStatusMessageWithoutMissingScopesUsesEnglishDetailsHeading(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, nil) + + writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{ + Requested: []string{"im:message:send"}, + Granted: []string{"im:message:send"}, + StatusMessage: "Authorization status details", + }) + + got := stderr.String() + if !strings.Contains(got, "- Authorization details:\n Authorization status details") { + t.Fatalf("stderr missing neutral authorization details heading, got:\n%s", got) + } + if strings.Contains(got, "- Scopes not granted in this request:") { + t.Fatalf("stderr should not label status_message as not granted when no scopes are missing, got:\n%s", got) + } } func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { @@ -513,15 +610,14 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { Granted: []string{"im:message:send", "im:message:reply"}, }, expectedPresent: []string{ - "授权成功! 用户: tester (ou_user)", - "本次请求 scopes: im:message:send im:message:reply", - "本次新授予 scopes: im:message:send", - "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + "登录成功! 用户: tester (ou_user)", + "登录成功! 用户: tester (ou_user)\n\n本次已成功授权:\n im:message:send、im:message:reply", }, expectedAbsent: []string{ - "本次未授予 scopes:", - "最终已授权 scopes:", - "已有 scopes:", + "以下是本次未授予的权限", + "本次请求 scopes:", + "本次新授予 scopes:", + "lark-cli auth status", }, }, { @@ -532,14 +628,13 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { Granted: []string{"im:message:send", "contact:user.base:readonly"}, }, expectedPresent: []string{ - "本次请求 scopes: im:message:send", - "本次新授予 scopes: (空)", - "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + "本次已成功授权:\n im:message:send", }, expectedAbsent: []string{ - "本次未授予 scopes:", - "最终已授权 scopes:", - "已有 scopes:", + "以下是本次未授予的权限", + "本次请求 scopes:", + "本次新授予 scopes:", + "lark-cli auth status", }, }, { @@ -550,14 +645,12 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) { Granted: []string{"im:message:reply"}, }, expectedPresent: []string{ - "本次请求 scopes: im:message:send im:message:reply", - "本次新授予 scopes: (空)", + "本次已成功授权:\n im:message:reply\n\n以下是本次未授予的权限:\n im:message:send", }, expectedAbsent: []string{ - "本次未授予 scopes:", - "已有 scopes:", - "最终已授权 scopes:", - "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + "本次请求 scopes:", + "本次新授予 scopes:", + "lark-cli auth status", }, }, } @@ -599,6 +692,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) keyring.MockInit() setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) + const statusMessage = "[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核:offline_access" multi := &core.MultiAppConfig{ CurrentApp: "default", @@ -638,6 +732,7 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) "expires_in": 7200, "refresh_token_expires_in": 604800, "scope": "offline_access", + "status_message": statusMessage, }, }) reg.Register(&httpmock.Stub{ @@ -666,25 +761,24 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) } got := stderr.String() for _, want := range []string{ - "授权结果异常: 以下请求 scopes 未被授予: im:message:send", - "当前授权账号: tester (ou_user)", - "本次请求 scopes: im:message:send", - "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试", - "scope 被禁用", - "lark-cli auth status", + "OK: 登录成功! 用户: tester (ou_user)", + "本次已成功授权:\n (空)\n\n以下是本次未授予的权限:\n " + statusMessage, } { if !strings.Contains(got, want) { t.Fatalf("stderr missing %q, got:\n%s", want, got) } } - if strings.Contains(got, "最终已授权 scopes:") { - t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got) - } - if strings.Contains(got, "OK: 授权成功") { - t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got) - } - if strings.Contains(got, "本次未授予 scopes:") { - t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got) + for _, unwanted := range []string{ + "授权结果异常:", + "本次请求 scopes:", + "本次新授予 scopes:", + "以上结果是本次授权请求用户最终确认后的结果", + "lark-cli auth status", + "当前授权账号:", + } { + if strings.Contains(got, unwanted) { + t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got) + } } if strings.Contains(got, "ERROR:") { t.Fatalf("stderr should not contain error prefix, got:\n%s", got) @@ -715,6 +809,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { keyring.MockInit() setupLoginConfigDir(t) t.Setenv("HOME", t.TempDir()) + const statusMessage = "[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核:offline_access" multi := &core.MultiAppConfig{ CurrentApp: "default", @@ -754,6 +849,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { "expires_in": 7200, "refresh_token_expires_in": 604800, "scope": "im:message:send offline_access", + "status_message": statusMessage, }, }) reg.Register(&httpmock.Stub{ @@ -795,24 +891,24 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { } got := stderr.String() for _, want := range []string{ - "OK: 授权成功! 用户: tester (ou_user)", - "本次请求 scopes: im:message:send", - "本次新授予 scopes: im:message:send", - "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + "OK: 登录成功! 用户: tester (ou_user)", + "本次已成功授权:\n im:message:send\n\n本次授权结果详情:\n " + statusMessage, } { if !strings.Contains(got, want) { t.Fatalf("stderr missing %q, got:\n%s", want, got) } } - if strings.Contains(got, "最终已授权 scopes:") { - t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got) + for _, unwanted := range []string{"本次请求 scopes:", "本次新授予 scopes:", "lark-cli auth status"} { + if strings.Contains(got, unwanted) { + t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got) + } } if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" { t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err) } } -func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) { +func TestWriteLoginSuccess_TextOutputEnglishUsesCompactScopeSummary(t *testing.T) { f, _, stderr, _ := cmdutil.TestFactory(t, nil) writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{ @@ -824,16 +920,18 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope got := stderr.String() for _, want := range []string{ "Authorization successful! User: tester (ou_user)", - "Requested scopes: im:message:send", - "Newly granted scopes: im:message:send", - "Run `lark-cli auth status` to inspect all scopes currently granted to the account.", + "Authorization successful! User: tester (ou_user)\n\n" + + "- Successfully authorized in this request:\n" + + " im:message:send", } { if !strings.Contains(got, want) { t.Fatalf("stderr missing %q, got:\n%s", want, got) } } - if strings.Contains(got, "Not granted scopes:") { - t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got) + for _, unwanted := range []string{"Scopes not granted in this request", "Requested scopes:", "Newly granted scopes:", "lark-cli auth status"} { + if strings.Contains(got, unwanted) { + t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got) + } } } @@ -1167,15 +1265,6 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t * } } -func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { - domains := getDomainMetadata("zh") - for _, dm := range domains { - if dm.Name == "event" { - t.Error("event should not appear in interactive domain list") - } - } -} - func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) { domains := allKnownDomains("") if domains["whiteboard"] { @@ -1200,12 +1289,3 @@ func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) { t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)") } } - -func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) { - domains := getDomainMetadata("zh") - for _, dm := range domains { - if dm.Name == "whiteboard" { - t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)") - } - } -} diff --git a/cmd/diagnose_scope_test.go b/cmd/diagnose_scope_test.go index 6be3edbf6d..27a780a28b 100644 --- a/cmd/diagnose_scope_test.go +++ b/cmd/diagnose_scope_test.go @@ -28,7 +28,6 @@ type diagMethodEntry struct { type diagScopeInfo struct { Scope string `json:"scope"` - Recommend bool `json:"recommend"` InPriority bool `json:"in_priority"` } @@ -69,7 +68,6 @@ type methodKey struct { // diagBuild builds the full output: flat methods list (merged identities) + scopes. func diagBuild(domains []string) diagOutput { - recommend := registry.LoadAutoApproveSet() identities := []string{"user", "bot"} merged := make(map[methodKey]*diagMethodEntry) @@ -142,7 +140,7 @@ func diagBuild(domains []string) diagOutput { scopes := make([]diagScopeInfo, len(scopeList)) for i, s := range scopeList { _, inPri := priorities[s] - scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri} + scopes[i] = diagScopeInfo{Scope: s, InPriority: inPri} } return diagOutput{Methods: methods, Scopes: scopes} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 7acdad859c..292c5b3a86 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -34,6 +34,7 @@ type DeviceFlowTokenData struct { ExpiresIn int RefreshExpiresIn int Scope string + StatusMessage string } // DeviceFlowResult is the result of polling the token endpoint. @@ -222,6 +223,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec ExpiresIn: tokenExpiresIn, RefreshExpiresIn: refreshExpiresIn, Scope: getStr(data, "scope"), + StatusMessage: getStr(data, "status_message"), }, } } diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 397098cc18..a28fc83420 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "fmt" + "io" "log" "net/http" "strings" @@ -216,3 +217,60 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) { t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got) } } + +func TestPollDeviceToken_PreservesStatusMessage(t *testing.T) { + t.Parallel() + + const statusMessage = "[不可申请,勿重试] 企业管理员禁止申请的权限:mail:user_mailbox.message:send\n" + + "[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核:offline_access" + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{ + "code": 0, + "access_token": "access-token", + "expires_in": 7200, + "scope": "approval:task:read", + "status_message": "[不可申请,勿重试] 企业管理员禁止申请的权限:mail:user_mailbox.message:send\n[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核:offline_access" + }`)), + }, nil + }), + } + + result := PollDeviceToken(context.Background(), client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 3, nil) + if result == nil || !result.OK || result.Token == nil { + t.Fatalf("PollDeviceToken() = %#v, want successful token result", result) + } + if result.Token.StatusMessage != statusMessage { + t.Fatalf("StatusMessage = %q, want %q", result.Token.StatusMessage, statusMessage) + } +} + +func TestPollDeviceToken_MissingStatusMessageIsEmpty(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{ + "code": 0, + "access_token": "access-token", + "expires_in": 7200, + "scope": "approval:task:read" + }`)), + }, nil + }), + } + + result := PollDeviceToken(context.Background(), client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 3, nil) + if result == nil || !result.OK || result.Token == nil { + t.Fatalf("PollDeviceToken() = %#v, want successful token result", result) + } + if result.Token.StatusMessage != "" { + t.Fatalf("StatusMessage = %q, want empty string", result.Token.StatusMessage) + } +} diff --git a/internal/auth/remote_scopes.go b/internal/auth/remote_scopes.go new file mode 100644 index 0000000000..6be2f18bbb --- /dev/null +++ b/internal/auth/remote_scopes.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/transport" +) + +const ( + remoteScopesPath = "/lark-cli/apis/scopes.json" + remoteScopesTimeout = 1 * time.Second + maxRemoteScopesSize = 10 * 1024 * 1024 // 10MB, aligned with internal/registry/remote.go +) + +// remoteScopesURLForTest is the injection seam for unit tests to point at an +// httptest server. It is empty at runtime, where the brand-hardcoded production +// URL is used, and must never carry an internal / non-production domain. +var remoteScopesURLForTest string + +type remoteScopesFile struct { + Scopes map[string]remoteDomainScopes `json:"scopes"` +} + +type remoteDomainScopes struct { + UserScopes []string `json:"user_scopes"` + TenantScopes []string `json:"tenant_scopes"` +} + +func remoteScopesURL(brand core.LarkBrand) string { + if remoteScopesURLForTest != "" { + return remoteScopesURLForTest + } + return core.ResolveOpenBaseURL(brand) + remoteScopesPath +} + +// FetchRemoteScopes fetches and binary-validates the remote scopes.json. +// It returns (domain -> user_scopes, true) when the whole file is usable, or +// (nil, false) when the caller should fall back to the local set. Any failure +// (network / timeout / non-2xx / empty / bad JSON / structure mismatch / +// malformed scope) returns (nil, false) silently — no warning, no telemetry. +func FetchRemoteScopes(brand core.LarkBrand) (map[string][]string, bool) { + client := transport.NewHTTPClient(remoteScopesTimeout) + req, err := http.NewRequest(http.MethodGet, remoteScopesURL(brand), nil) + if err != nil { + return nil, false + } + resp, err := client.Do(req) + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, false + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxRemoteScopesSize)) + if err != nil || len(body) == 0 { + return nil, false + } + var file remoteScopesFile + if err := json.Unmarshal(body, &file); err != nil { + return nil, false + } + return validateRemoteScopes(file) +} + +func validateRemoteScopes(file remoteScopesFile) (map[string][]string, bool) { + if len(file.Scopes) == 0 { + return nil, false + } + result := make(map[string][]string, len(file.Scopes)) + for domain, ds := range file.Scopes { + if ds.UserScopes == nil { // missing user_scopes field / null → whole file untrusted + return nil, false + } + for _, s := range ds.UserScopes { + if !isValidScopeFormat(s) { + return nil, false + } + } + result[domain] = ds.UserScopes + } + return result, true +} + +// isValidScopeFormat does a light corruption check on a scope string. Lark +// scopes are ":"-separated but the segment count is NOT fixed: real scopes +// range from two segments ("im:message", "mail:event") through three +// ("base:app:copy") to four ("board:whiteboard:node:read", +// "drive:file:view_record:readonly"), and a "." may appear in any segment +// ("im:message.send_as_user", "vc:meeting.meetingevent:read"). We therefore +// only require at least two segments, each non-empty and built from the +// characters Lark uses (lowercase letters, digits, "_", "."). This still +// rejects empty segments and whitespace — a whitespace character would corrupt +// the space-joined OAuth scope string on the wire — as well as other garbage; +// under the binary model any such scope discards the whole remote file. The +// real defence against a tampered file is the trusted HTTPS source plus the +// IAM authorization page, not this shape check, so it is deliberately lenient +// on segment count to avoid falsely rejecting a valid published file. +// i18n / tenant / version are not checked. +func isValidScopeFormat(s string) bool { + parts := strings.Split(s, ":") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + for _, c := range p { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.') { + return false + } + } + } + return true +} diff --git a/internal/auth/remote_scopes_test.go b/internal/auth/remote_scopes_test.go new file mode 100644 index 0000000000..f51f4b3894 --- /dev/null +++ b/internal/auth/remote_scopes_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/core" +) + +func withRemoteScopesURL(t *testing.T, url string) { + t.Helper() + prev := remoteScopesURLForTest + remoteScopesURLForTest = url + t.Cleanup(func() { remoteScopesURLForTest = prev }) +} + +func TestFetchRemoteScopes(t *testing.T) { + cases := []struct { + name string + status int + body string + wantOK bool + wantDom string + wantLen int + }{ + { + name: "whole usable returns all user_scopes incl unknown domain", + status: 200, + body: `{"version":"1.5.3","scopes":{"bitable":{"i18n_name":{"zh_cn":"多维表格"},"user_scopes":["base:app:copy","base:app:create"],"tenant_scopes":["base:app:copy"]},"brandnewdomain":{"user_scopes":["newsvc:res:read"]}}}`, + wantOK: true, + wantDom: "brandnewdomain", + wantLen: 1, + }, + {name: "missing scopes key falls back", status: 200, body: `{"version":"1"}`, wantOK: false}, + {name: "empty scopes falls back", status: 200, body: `{"scopes":{}}`, wantOK: false}, + {name: "domain missing user_scopes falls back", status: 200, body: `{"scopes":{"im":{"i18n_name":{"zh_cn":"消息"}}}}`, wantOK: false}, + {name: "empty-segment scope falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["im::message"]}}}`, wantOK: false}, + {name: "single-segment scope falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["nocolon"]}}}`, wantOK: false}, + {name: "scope with whitespace falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["im message:chat:read"]}}}`, wantOK: false}, + {name: "variable-segment scopes usable (2/3/4 seg incl dot)", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message","im:message.send_as_user","base:app:copy","board:whiteboard:node:read"]}}}`, wantOK: true, wantDom: "im", wantLen: 4}, + {name: "non-2xx falls back", status: 500, body: `{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`, wantOK: false}, + {name: "empty body falls back", status: 200, body: ``, wantOK: false}, + {name: "bad json falls back", status: 200, body: `{not json`, wantOK: false}, + {name: "i18n/tenant missing still usable", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message:send_as_bot","vc:meeting.meetingevent:read"]}}}`, wantOK: true, wantDom: "im", wantLen: 2}, + {name: "empty user_scopes array is usable", status: 200, body: `{"scopes":{"im":{"user_scopes":[]}}}`, wantOK: true, wantDom: "im", wantLen: 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + t.Cleanup(srv.Close) + withRemoteScopesURL(t, srv.URL) + + got, ok := FetchRemoteScopes(core.BrandFeishu) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if tc.wantOK { + if _, exists := got[tc.wantDom]; !exists { + t.Fatalf("domain %q missing in result %v", tc.wantDom, got) + } + if len(got[tc.wantDom]) != tc.wantLen { + t.Fatalf("len(%s) = %d, want %d", tc.wantDom, len(got[tc.wantDom]), tc.wantLen) + } + } + }) + } +} + +func TestFetchRemoteScopesTimeoutFallsBack(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(remoteScopesTimeout + 500*time.Millisecond) + _, _ = w.Write([]byte(`{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`)) + })) + t.Cleanup(srv.Close) + withRemoteScopesURL(t, srv.URL) + + _, ok := FetchRemoteScopes(core.BrandFeishu) + if ok { + t.Fatal("expected fallback (ok=false) on timeout") + } +} + +func TestRemoteScopesURLByBrand(t *testing.T) { + // With an empty seam the production URL is used: core.ResolveOpenBaseURL(brand) + path + remoteScopesURLForTest = "" + feishu := remoteScopesURL(core.BrandFeishu) + lark := remoteScopesURL(core.BrandLark) + if !strings.HasSuffix(feishu, "/lark-cli/apis/scopes.json") || !strings.Contains(feishu, "open.feishu.cn") { + t.Fatalf("feishu url unexpected: %s", feishu) + } + if !strings.Contains(lark, "open.larksuite.com") { + t.Fatalf("lark url unexpected: %s", lark) + } +} diff --git a/internal/registry/loader.go b/internal/registry/loader.go index ab2bbaba16..9eb7ef56d9 100644 --- a/internal/registry/loader.go +++ b/internal/registry/loader.go @@ -170,16 +170,11 @@ func ListFromMetaProjects() []string { const DefaultScopeScore = 0 var cachedScopePriorities map[string]int -var cachedAutoApproveSet map[string]bool -var cachedPlatformAutoApprove map[string]bool // from scope_priorities.json only -var cachedOverrideAutoAllow map[string]bool // from scope_overrides.json allow only -var cachedOverrideAutoDeny map[string]bool // from scope_overrides.json deny only // scopePriorityEntry is used to parse scope_priorities.json entries. type scopePriorityEntry struct { ScopeName string `json:"scope_name"` FinalScore string `json:"final_score"` - Recommend string `json:"recommend"` } // LoadScopePriorities loads the scope priorities map from scope_priorities.json. @@ -226,134 +221,6 @@ func LoadScopePriorities() map[string]int { return cachedScopePriorities } -// LoadAutoApproveSet returns the set of auto-approve scope names. -// Sources (merged): recommend=="true" in scope_priorities.json -// + explicit allow/deny in scope_overrides.json. -func LoadAutoApproveSet() map[string]bool { - if cachedAutoApproveSet != nil { - return cachedAutoApproveSet - } - - m := make(map[string]bool) - - // 1. From scope_priorities.json (Recommend == "true") - if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil { - var entries []scopePriorityEntry - if json.Unmarshal(data, &entries) == nil { - for _, entry := range entries { - if entry.Recommend == "true" { - m[entry.ScopeName] = true - } - } - } - } - - // 2. From scope_overrides.json (recommend.allow/deny lists) - if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { - var wrapper struct { - AutoApprove struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` - } `json:"recommend"` - } - if json.Unmarshal(data, &wrapper) == nil { - for _, s := range wrapper.AutoApprove.Allow { - m[s] = true - } - for _, s := range wrapper.AutoApprove.Deny { - delete(m, s) - } - } - } - - cachedAutoApproveSet = m - return cachedAutoApproveSet -} - -// LoadPlatformAutoApproveSet returns scopes with AutoApprove rule on the platform -// (from scope_priorities.json only, before overrides). -func LoadPlatformAutoApproveSet() map[string]bool { - if cachedPlatformAutoApprove != nil { - return cachedPlatformAutoApprove - } - m := make(map[string]bool) - if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil { - var entries []scopePriorityEntry - if json.Unmarshal(data, &entries) == nil { - for _, entry := range entries { - if entry.Recommend == "true" { - m[entry.ScopeName] = true - } - } - } - } - cachedPlatformAutoApprove = m - return cachedPlatformAutoApprove -} - -// LoadOverrideAutoApproveAllow returns scopes explicitly listed in -// scope_overrides.json recommend.allow (our desired additions). -func LoadOverrideAutoApproveAllow() map[string]bool { - if cachedOverrideAutoAllow != nil { - return cachedOverrideAutoAllow - } - m := make(map[string]bool) - if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { - var wrapper struct { - AutoApprove struct { - Allow []string `json:"allow"` - } `json:"recommend"` - } - if json.Unmarshal(data, &wrapper) == nil { - for _, s := range wrapper.AutoApprove.Allow { - m[s] = true - } - } - } - cachedOverrideAutoAllow = m - return cachedOverrideAutoAllow -} - -// LoadOverrideAutoApproveDeny returns scopes explicitly listed in -// scope_overrides.json recommend.deny -func LoadOverrideAutoApproveDeny() map[string]bool { - if cachedOverrideAutoDeny != nil { - return cachedOverrideAutoDeny - } - m := make(map[string]bool) - if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { - var wrapper struct { - AutoApprove struct { - Deny []string `json:"deny"` - } `json:"recommend"` - } - if json.Unmarshal(data, &wrapper) == nil { - for _, s := range wrapper.AutoApprove.Deny { - m[s] = true - } - } - } - cachedOverrideAutoDeny = m - return cachedOverrideAutoDeny -} - -// IsAutoApproveScope returns true if the scope has AutoApprove rule. -func IsAutoApproveScope(scope string) bool { - return LoadAutoApproveSet()[scope] -} - -// FilterAutoApproveScopes filters a scope list to only include auto-approve scopes. -func FilterAutoApproveScopes(scopes []string) []string { - autoApprove := LoadAutoApproveSet() - var result []string - for _, s := range scopes { - if autoApprove[s] { - result = append(result, s) - } - } - return result -} - // GetScopeScore returns the priority score for a scope, or DefaultScopeScore if not found. func GetScopeScore(scope string) int { priorities := LoadScopePriorities() diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 8906e3f94e..010aac2446 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -219,107 +219,6 @@ func TestFilterScopes_TooFewParts(t *testing.T) { } } -// --- Auto-approve functions --- - -func TestLoadAutoApproveSet(t *testing.T) { - aaSet := LoadAutoApproveSet() - if len(aaSet) == 0 { - t.Fatal("expected non-empty auto-approve set") - } - - // From scope_priorities.json recommend=="true" - if !aaSet["sheets:spreadsheet:read"] { - t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)") - } - - t.Logf("Auto-approve set has %d scopes", len(aaSet)) -} - -func TestLoadPlatformAutoApproveSet(t *testing.T) { - paaSet := LoadPlatformAutoApproveSet() - // This should only include scopes from scope_priorities.json with AutoApprove rule. - // It does NOT apply deny overrides. - if len(paaSet) == 0 { - t.Fatal("expected non-empty platform auto-approve set") - } - - t.Logf("Platform auto-approve set has %d scopes", len(paaSet)) -} - -func TestLoadOverrideAutoApproveAllow(t *testing.T) { - allowSet := LoadOverrideAutoApproveAllow() - // recommend.allow special-cases scopes absent from scope_priorities.json - // (application v7 is not in the platform catalog yet) so interactive - // login's "common scopes" tier still offers them. Only the read scope is - // admitted: write stays out of the recommended tier by design. - if !allowSet["application:app_slash_command:read"] { - t.Error("expected application:app_slash_command:read in override allow set") - } - if allowSet["application:app_slash_command:write"] { - t.Error("write scope must NOT be in the recommended tier") - } - if len(allowSet) != 1 { - t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet)) - } -} - -func TestLoadOverrideAutoApproveDeny(t *testing.T) { - denySet := LoadOverrideAutoApproveDeny() - // deny list may be empty if all entries are moved to _deny (commented out) - t.Logf("Override deny set has %d scopes", len(denySet)) -} - -func TestIsAutoApproveScope(t *testing.T) { - // Known auto-approve scope (recommend=true in scope_priorities.json) - if !IsAutoApproveScope("sheets:spreadsheet:read") { - t.Error("expected sheets:spreadsheet:read to be auto-approve") - } - - // Completely unknown scope - if IsAutoApproveScope("zzz:unknown:scope") { - t.Error("expected unknown scope to NOT be auto-approve") - } -} - -func TestFilterAutoApproveScopes(t *testing.T) { - scopes := []string{ - "sheets:spreadsheet:read", // auto-approve (recommend=true in priorities) - "zzz:unknown:scope", // not in auto-approve - } - - result := FilterAutoApproveScopes(scopes) - if len(result) < 1 { - t.Fatal("expected at least 1 auto-approve scope in result") - } - - // Check that sheets:spreadsheet:read is included - found := false - for _, s := range result { - if s == "sheets:spreadsheet:read" { - found = true - } - // Ensure unknown scopes are not included - if s == "zzz:unknown:scope" { - t.Error("unknown scope should not be in auto-approve result") - } - } - if !found { - t.Error("expected sheets:spreadsheet:read in result") - } -} - -func TestFilterAutoApproveScopes_Empty(t *testing.T) { - result := FilterAutoApproveScopes(nil) - if result != nil { - t.Errorf("expected nil, got %v", result) - } - - result = FilterAutoApproveScopes([]string{}) - if result != nil { - t.Errorf("expected nil for empty input, got %v", result) - } -} - // --- Helper functions --- func TestGetRegistryDir(t *testing.T) { diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index 15c7281c7b..59cb7f57f1 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -39,10 +39,6 @@ func resetInit() { embeddedVersion = "" cachedAllScopes = nil cachedScopePriorities = nil - cachedAutoApproveSet = nil - cachedPlatformAutoApprove = nil - cachedOverrideAutoAllow = nil - cachedOverrideAutoDeny = nil refreshOnce = sync.Once{} configuredBrand = "" enableRemoteMeta = true // tests exercise remote logic diff --git a/internal/registry/service_desc.go b/internal/registry/service_desc.go index da2a6b5fc3..022f050815 100644 --- a/internal/registry/service_desc.go +++ b/internal/registry/service_desc.go @@ -58,26 +58,6 @@ func GetServiceDescription(name, lang string) string { return loc.Description } -// GetServiceTitle returns the localized title for a service domain. -// Returns empty string if not found. -func GetServiceTitle(name, lang string) string { - loc := getServiceLocale(name, lang) - if loc == nil { - return "" - } - return loc.Title -} - -// GetServiceDetailDescription returns the localized detail description for a service domain. -// Returns empty string if not found. -func GetServiceDetailDescription(name, lang string) string { - loc := getServiceLocale(name, lang) - if loc == nil { - return "" - } - return loc.Description -} - // GetAuthDomain returns the auth_domain for a service, or "" if not set. // When auth_domain is set, the service's scopes are collected under the // parent domain during auth login.