Compare commits

...

6 Commits

Author SHA1 Message Date
zhaojunchang
b2da915dd2 fix(auth/login): improve login result heading and warning hint logic 2026-07-20 14:34:43 +08:00
zhaojunchang
edb18d3522 fix(auth): remove message field from login warning payload 2026-07-20 14:34:42 +08:00
zhaojunchang
a21465deee feat(auth): add support for status message from device flow auth 2026-07-20 14:34:42 +08:00
dc-bytedance
fa2357aa46 fix: accept variable segment counts in remote scope validation 2026-07-20 14:34:42 +08:00
dc-bytedance
f98e79f7db refactor: remove orphaned auto-approve loader and harden scope check 2026-07-20 14:34:42 +08:00
dc-bytedance
503dd9f693 feat: use remote scopes.json for login scope recommendations
Switch auth login's recommended-permission source from the compiled-in
local table to a scopes.json fetched from the platform at login time.

- add internal/auth/remote_scopes.go: brand-addressed GET with a ~1s
  timeout and whole-file (binary) validation; scopes are used verbatim
  when the file is valid, and any fetch/parse/format failure falls back
  silently to the existing local computation
- login.go: fetch remote scopes once per login and use them for domain
  validation, all-expansion, and per-domain scope selection; drop the
  terminal interactive page and the local auto-approve filter chain, so
  --recommend is now equivalent to --domain all
- remove login_interactive.go and the service-description getters left
  orphaned by the interactive-page removal
- keep the three entry flags (bare login / --recommend / --domain all)
  as an equivalent transitional surface
2026-07-20 14:34:40 +08:00
15 changed files with 723 additions and 818 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 <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 <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 <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 <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"}
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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:sendim: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)")
}
}
}

View File

@@ -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}

View File

@@ -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"),
},
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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

View File

@@ -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.