From 75793a8d64948e448b8a7582a5a4c86fdbf3abe8 Mon Sep 17 00:00:00 2001 From: "yilin.wang" Date: Mon, 13 Jul 2026 21:25:50 +0800 Subject: [PATCH] fix(auth): make agent authorization resumable --- cmd/auth/auth_test.go | 2 +- cmd/auth/login.go | 35 ++++++++++--- cmd/auth/login_messages.go | 4 +- cmd/auth/login_messages_test.go | 4 +- cmd/auth/login_scope_cache.go | 65 ++++++++++++++++++++++++ cmd/auth/login_scope_cache_test.go | 79 ++++++++++++++++++++++++++++++ cmd/auth/login_test.go | 18 +++---- cmd/config/init.go | 10 +++- cmd/config/init_guard_test.go | 32 ++++++++++++ skills/lark-shared/SKILL.md | 16 +++--- 10 files changed, 234 insertions(+), 31 deletions(-) diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index f633a61433..55882b826c 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -63,7 +63,7 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) { "only delivers final turn messages", "--no-wait --json", "send the verification URL (or QR code) to the user as your final message", - "run --device-code in a later step", + "run --resume in a later step", } { if !strings.Contains(got, want) { t.Fatalf("help missing %q, got:\n%s", want, got) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 3b240b1765..cd344aa8a0 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -6,7 +6,9 @@ package auth import ( "context" "encoding/json" + "errors" "fmt" + "os" "sort" "strings" "time" @@ -36,6 +38,7 @@ type LoginOptions struct { Exclude []string NoWait bool DeviceCode string + Resume bool } var pollDeviceToken = larkauth.PollDeviceToken @@ -52,7 +55,7 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra. 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 -run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode' +run --resume in a later step after the user confirms authorization. Use 'lark-cli auth qrcode' to generate QR codes (supports ASCII and PNG formats).`, RunE: func(cmd *cobra.Command, args []string) error { if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { @@ -84,8 +87,9 @@ to generate QR codes (supports ASCII and PNG formats).`, cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil, "scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)") cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") - cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete") + cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --resume to complete later") cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call") + cmd.Flags().BoolVar(&opts.Resume, "resume", false, "resume the latest pending authorization created by --no-wait") cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp @@ -132,6 +136,21 @@ func authLoginRun(opts *LoginOptions) error { } msg := getLoginMsg(lang) + if opts.Resume { + if opts.DeviceCode != "" || opts.NoWait || opts.Scope != "" || opts.Recommend || len(opts.Domains) > 0 || len(opts.Exclude) > 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--resume cannot be combined with authorization request options or --device-code").WithParam("--resume") + } + pending, err := loadPendingLogin(config.AppID) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return errs.NewAuthenticationError(errs.SubtypeUnknown, "no unexpired pending authorization to resume"). + WithHint("start a fresh split flow with `lark-cli auth login --scope --no-wait --json`, send its verification URL to the user, then run `lark-cli auth login --resume` only after the user confirms authorization") + } + return errs.NewInternalError(errs.SubtypeStorage, "failed to load pending authorization: %v", err).WithCause(err) + } + opts.DeviceCode = pending.DeviceCode + } + log := func(format string, a ...interface{}) { if !opts.JSON { fmt.Fprintf(f.IOStreams.ErrOut, format+"\n", a...) @@ -200,7 +219,7 @@ func authLoginRun(opts *LoginOptions) error { 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.") + 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 --resume in a later step after the user confirms authorization.") return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope") } } @@ -272,7 +291,7 @@ func authLoginRun(opts *LoginOptions) error { // --no-wait: return immediately with device code and URL if opts.NoWait { - if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil { + if err := savePendingLogin(authResp.DeviceCode, config.AppID, finalScope, authResp.ExpiresIn); err != nil { fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err) } data := map[string]interface{}{ @@ -283,9 +302,9 @@ func authLoginRun(opts *LoginOptions) error { "**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." + - "For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" + - "**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code ` yourself." + - "**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.", + "For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not run --resume in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" + + "**After the user confirms authorization:** YOU must execute `lark-cli auth login --resume` yourself. The CLI keeps the pending device code locally; do not copy it into a task comment or ask the user to run a command." + + "If --resume reports that the pending authorization expired, start a fresh flow with `lark-cli auth login --no-wait --json` and show the new URL.", } encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) @@ -403,7 +422,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err) } cleanupRequestedScope := func() { - if err := removeLoginRequestedScope(opts.DeviceCode); err != nil { + if err := removePendingLogin(opts.DeviceCode, config.AppID); err != nil { fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err) } } diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 2dee8992f7..a350dcd9a1 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -61,7 +61,7 @@ var loginMsgZh = &loginMsg{ 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 编码/解码、添加空格或标点)。", + AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 verification_url,把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --resume\" 续上轮询。CLI 会在本机保存 pending device code,不要把它写入回复或任务评论。**不要在同一轮里展示 URL 后立刻阻塞执行 --resume**,也不要短 timeout 反复重试;如果 pending 授权过期,再发起一条新的链接。**必须生成二维码并展示**: 你必须调用 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)", @@ -97,7 +97,7 @@ var loginMsgEn = &loginMsg{ 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.", + 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 verification_url, present it 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 --resume\" in a later step to resume polling. The CLI stores the pending device code locally; do not put it in a response or task comment. **Do NOT show the URL and then immediately block on --resume in the same turn**, and do not retry with a short timeout; start a fresh flow only if the pending authorization expires.**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)", diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go index a5c7f936ca..6dfb2788ee 100644 --- a/cmd/auth/login_messages_test.go +++ b/cmd/auth/login_messages_test.go @@ -101,12 +101,12 @@ func TestLoginMsg_FormatStrings(t *testing.T) { // TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous // auth-login output tells AI agents three things: (a) this command blocks for // minutes — set a long runner timeout, (b) the alternative is the --no-wait + -// --device-code split-flow, and (c) non-streaming harnesses must end the turn +// --resume split-flow, and (c) non-streaming harnesses must end the turn // after presenting the URL instead of blocking in the same turn. func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) { for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} { hint := getLoginMsg(lang).AgentTimeoutHint - for _, want := range []string{"--no-wait", "--device-code", "turn"} { + for _, want := range []string{"--no-wait", "--resume", "turn"} { if lang == i18n.LangZhCN && want == "turn" { want = "本轮" } diff --git a/cmd/auth/login_scope_cache.go b/cmd/auth/login_scope_cache.go index ad8036bdaa..354a155fa6 100644 --- a/cmd/auth/login_scope_cache.go +++ b/cmd/auth/login_scope_cache.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "time" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" @@ -22,6 +23,13 @@ type loginScopeCacheRecord struct { RequestedScope string `json:"requested_scope"` } +type pendingLoginRecord struct { + DeviceCode string `json:"device_code"` + AppID string `json:"app_id"` + RequestedScope string `json:"requested_scope"` + ExpiresAt int64 `json:"expires_at"` +} + // loginScopeCacheDir returns the directory used to persist auth login --no-wait // requested scopes keyed by device_code. func loginScopeCacheDir() string { @@ -33,6 +41,10 @@ func loginScopeCachePath(deviceCode string) string { return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json") } +func pendingLoginPath(appID string) string { + return filepath.Join(loginScopeCacheDir(), "latest-"+sanitizeLoginScopeCacheKey(appID)+".json") +} + // sanitizeLoginScopeCacheKey converts a device_code into a safe filename token. func sanitizeLoginScopeCacheKey(deviceCode string) string { sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_") @@ -54,6 +66,45 @@ func saveLoginRequestedScope(deviceCode, requestedScope string) error { return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600) } +// savePendingLogin persists the latest split-flow authorization so a later +// agent turn can resume it without copying a device code through the model's +// conversation context. The per-device scope record remains for backwards +// compatibility with the explicit --device-code flow. +func savePendingLogin(deviceCode, appID, requestedScope string, expiresIn int) error { + if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil { + return err + } + record := pendingLoginRecord{ + DeviceCode: deviceCode, + AppID: appID, + RequestedScope: requestedScope, + ExpiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second).Unix(), + } + data, err := json.Marshal(record) + if err != nil { + return err + } + return validate.AtomicWrite(pendingLoginPath(appID), data, 0600) +} + +func loadPendingLogin(appID string) (*pendingLoginRecord, error) { + path := pendingLoginPath(appID) + data, err := vfs.ReadFile(path) + if err != nil { + return nil, err + } + var record pendingLoginRecord + if err := json.Unmarshal(data, &record); err != nil { + _ = vfs.Remove(path) + return nil, err + } + if record.DeviceCode == "" || record.AppID != appID || record.ExpiresAt <= time.Now().Unix() { + _ = vfs.Remove(path) + return nil, os.ErrNotExist + } + return &record, nil +} + // loadLoginRequestedScope loads the cached requested scope string for a device_code. // It returns an empty string if no cache entry exists. func loadLoginRequestedScope(deviceCode string) (string, error) { @@ -81,6 +132,20 @@ func removeLoginRequestedScope(deviceCode string) error { return err } +func removePendingLogin(deviceCode, appID string) error { + firstErr := removeLoginRequestedScope(deviceCode) + path := pendingLoginPath(appID) + if data, err := vfs.ReadFile(path); err == nil { + var record pendingLoginRecord + if json.Unmarshal(data, &record) == nil && record.DeviceCode == deviceCode { + if err := vfs.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) && firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + // shouldRemoveLoginRequestedScope indicates whether the requested-scope cache // should be removed after polling finishes. func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool { diff --git a/cmd/auth/login_scope_cache_test.go b/cmd/auth/login_scope_cache_test.go index b2cc1bba0d..fbc7d25b05 100644 --- a/cmd/auth/login_scope_cache_test.go +++ b/cmd/auth/login_scope_cache_test.go @@ -4,9 +4,11 @@ package auth import ( + "encoding/json" "errors" "os" "testing" + "time" "github.com/larksuite/cli/internal/vfs" ) @@ -49,3 +51,80 @@ func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) { t.Fatalf("requestedScope = %q, want empty", got) } } + +func TestPendingLoginCache_ResumesPerAppAndCleansUp(t *testing.T) { + setupLoginConfigDir(t) + + if err := savePendingLogin("device-a", "cli_a", "scope:a", 600); err != nil { + t.Fatalf("savePendingLogin(cli_a) error = %v", err) + } + if err := savePendingLogin("device-b", "cli_b", "scope:b", 600); err != nil { + t.Fatalf("savePendingLogin(cli_b) error = %v", err) + } + + a, err := loadPendingLogin("cli_a") + if err != nil || a.DeviceCode != "device-a" || a.RequestedScope != "scope:a" { + t.Fatalf("loadPendingLogin(cli_a) = (%+v, %v)", a, err) + } + b, err := loadPendingLogin("cli_b") + if err != nil || b.DeviceCode != "device-b" || b.RequestedScope != "scope:b" { + t.Fatalf("loadPendingLogin(cli_b) = (%+v, %v)", b, err) + } + + if err := removePendingLogin("device-a", "cli_a"); err != nil { + t.Fatalf("removePendingLogin(cli_a) error = %v", err) + } + if _, err := loadPendingLogin("cli_a"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("loadPendingLogin(cli_a) after cleanup error = %v, want not exist", err) + } + if b, err := loadPendingLogin("cli_b"); err != nil || b.DeviceCode != "device-b" { + t.Fatalf("cli_b pending flow must survive cli_a cleanup: (%+v, %v)", b, err) + } +} + +func TestPendingLoginCache_ExpiredRecordIsRemoved(t *testing.T) { + setupLoginConfigDir(t) + + record := pendingLoginRecord{ + DeviceCode: "expired-device", + AppID: "cli_expired", + RequestedScope: "scope:expired", + ExpiresAt: time.Now().Add(-time.Second).Unix(), + } + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := vfs.WriteFile(pendingLoginPath(record.AppID), data, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + if _, err := loadPendingLogin(record.AppID); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("loadPendingLogin() error = %v, want not exist", err) + } + if _, err := vfs.Stat(pendingLoginPath(record.AppID)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(pendingPath) error = %v, want not exist", err) + } +} + +func TestPendingLoginCache_LatestFlowForSameAppWins(t *testing.T) { + setupLoginConfigDir(t) + + if err := savePendingLogin("device-first", "cli_same", "scope:first", 600); err != nil { + t.Fatalf("savePendingLogin(first) error = %v", err) + } + if err := savePendingLogin("device-second", "cli_same", "scope:second", 600); err != nil { + t.Fatalf("savePendingLogin(second) error = %v", err) + } + + got, err := loadPendingLogin("cli_same") + if err != nil { + t.Fatalf("loadPendingLogin() error = %v", err) + } + if got.DeviceCode != "device-second" || got.RequestedScope != "scope:second" { + t.Fatalf("loadPendingLogin() = %+v, want latest flow", got) + } +} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index f2aa3389a9..7de97ed840 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -324,7 +324,7 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { } // 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"} { + for _, want := range []string{"--no-wait --json", "final message of the turn", "--resume"} { if !strings.Contains(stderrStr, want) { t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr) } @@ -786,12 +786,12 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) { stderr.Reset() err = authLoginRun(&LoginOptions{ - Factory: f, - Ctx: context.Background(), - DeviceCode: "device-code", + Factory: f, + Ctx: context.Background(), + Resume: true, }) if err != nil { - t.Fatalf("device-code authLoginRun() error = %v", err) + t.Fatalf("resume authLoginRun() error = %v", err) } got := stderr.String() for _, want := range []string{ @@ -1045,11 +1045,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) { "cannot be modified", "final message of the turn", "return control to the user", - "do not block on --device-code in the same turn", + "do not run --resume in the same turn", "come back and notify", "YOU must execute", - "lark-cli auth login --device-code ", - "Do NOT cache", + "lark-cli auth login --resume", + "keeps the pending device code locally", "lark-cli auth login --no-wait --json", } { if !strings.Contains(hint, want) { @@ -1148,7 +1148,7 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t * "本轮最终消息", "结束本轮", "用户回复已完成授权", - "不要在同一轮里展示 URL 后立刻阻塞执行 --device-code", + "不要在同一轮里展示 URL 后立刻阻塞执行 --resume", "必须生成二维码并展示", "lark-cli auth qrcode", "优先生成 PNG 二维码(--output)", diff --git a/cmd/config/init.go b/cmd/config/init.go index 544c4c60be..fe86d09252 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -112,8 +112,9 @@ func validateInitLang(opts *ConfigInitOptions) error { } // guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or -// Hermes Agent context, because the Agent has already provisioned an app -// and 'config bind' is the right tool for hooking lark-cli into it. +// Hermes Agent context, or when a host explicitly marks the profile as +// managed. In each case the host already owns the app binding; creating a new +// app would shadow it rather than repair it. // Running init here would create a parallel app under the agent's workspace // dir, breaking the binding the user actually wants. --force-init lets a // human user override when they really do want a separate app. @@ -121,6 +122,11 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error { if opts.ForceInit { return nil } + if os.Getenv("LARKSUITE_CLI_MANAGED_CONFIG") == "1" { + return errs.NewConfigError(errs.SubtypeNotConfigured, + "config init is refused because this lark-cli profile is managed by the Agent host (creating another app would shadow the managed binding)"). + WithHint("do not create a new app or run config init in this Agent task. Retry the original command; if it remains not_configured, report that the host-managed lark-cli profile failed to provision. Pass --force-init only if the user explicitly wants a separate app.") + } ws := core.DetectWorkspaceFromEnv(os.Getenv) if ws.IsLocal() { return nil diff --git a/cmd/config/init_guard_test.go b/cmd/config/init_guard_test.go index 33ff69bcdf..3431e08aac 100644 --- a/cmd/config/init_guard_test.go +++ b/cmd/config/init_guard_test.go @@ -13,12 +13,36 @@ import ( func TestGuardAgentWorkspace_LocalAllows(t *testing.T) { clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_MANAGED_CONFIG", "") if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil { t.Errorf("local workspace should allow init, got: %v", err) } } +func TestGuardAgentWorkspace_ManagedProfileRefuses(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_MANAGED_CONFIG", "1") + + err := guardAgentWorkspace(&ConfigInitOptions{}) + if err == nil { + t.Fatal("expected refusal for a host-managed profile, got nil") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error type = %T, want *errs.ConfigError", err) + } + if !strings.Contains(cfgErr.Message, "managed by the Agent host") { + t.Errorf("message must identify host-managed config; got %q", cfgErr.Message) + } + if strings.Contains(cfgErr.Hint, "config bind") { + t.Errorf("managed profiles must not be redirected to config bind; got %q", cfgErr.Hint) + } + if !strings.Contains(cfgErr.Hint, "failed to provision") { + t.Errorf("hint must identify the host provisioning failure; got %q", cfgErr.Hint) + } +} + func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) { t.Setenv("OPENCLAW_HOME", t.TempDir()) @@ -71,3 +95,11 @@ func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) { t.Errorf("--force-init should bypass the guard, got: %v", err) } } + +func TestGuardAgentWorkspace_ForceInitOverridesManagedProfile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_MANAGED_CONFIG", "1") + + if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil { + t.Errorf("--force-init should bypass the managed-profile guard, got: %v", err) + } +} diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index e78ddd496b..d6ff9fb7f3 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -12,6 +12,8 @@ description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user 首次使用需运行 `lark-cli config init` 完成应用配置。 +先检查当前环境是否由 Agent host 托管:若 `LARKSUITE_CLI_MANAGED_CONFIG=1`,**禁止运行 `config init`**。此时 bot/app 由 host 自动 provision;`not_configured` 表示 host provision 失败,不是让你新建一个同名 bot。应重试原操作,仍失败则把 `not_configured` 原样报告给用户或宿主排障。不要用后台任务启动后再 `Stop-Job`/kill;那只会留下浏览器侧的新 app,却来不及写入本地配置。 + 当你帮用户初始化配置时,使用background方式使用下面的命令发起配置应用流程,启动后读取输出,从中提取授权链接并发给用户。 **URL 转发规则**:当命令输出 `verification_url`、`verification_uri_complete`、`console_url` 等 URL 字段时:**必须生成二维码**:你必须调用 `lark-cli auth qrcode` 将 URL 转为二维码并展示给用户,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**URL 输出规则**:将 URL 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点、重新拼接 query),二维码和链接请一起展示给用户。 @@ -95,12 +97,12 @@ lark-cli auth login --scope "" # 按具体 scope 授权(推 lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json ``` -拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行 `--device-code` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。 +拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行 `--resume` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。 用户回复已完成授权后,再在后续步骤执行: ```bash -lark-cli auth login --device-code +lark-cli auth login --resume ``` **Split-Flow 完整步骤**: @@ -108,7 +110,7 @@ lark-cli auth login --device-code **第一步:发起授权(当前轮)** 1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`) -2. 从 JSON 输出中提取 `verification_url` 和 `device_code` +2. 从 JSON 输出中提取 `verification_url`;`device_code` 由 CLI 安全地保存在本机 pending 状态中,不要转发 3. 生成二维码:`lark-cli auth qrcode --output "xxx"` 4. 将 URL 和二维码展示给用户(先 URL,后二维码) 5. **结束本轮对话前,必须明确告知用户**:"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤" @@ -116,15 +118,15 @@ lark-cli auth login --device-code **第二步:完成授权(后续轮)** 1. 等待用户回复"已完成授权" -2. **由你(AI agent)亲自执行**:`lark-cli auth login --device-code ` +2. **由你(AI agent)亲自执行**:`lark-cli auth login --resume` 3. 此命令会轮询授权状态并完成登录 4. 如果返回授权成功,流程结束 **关键规则**: -- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行 -- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL -- **禁止缓存 `verification_url` 或 `device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用 +- **你必须亲自执行 `--resume` 命令**,不要指示用户自行执行 +- **不要在同一轮中展示 URL 后立刻执行 `--resume`**,这会导致用户看不到 URL +- **不要把 `device_code` 写入回复、任务评论或上下文**;CLI 会在本机保存最近一次未过期流程。仅当 `--resume` 明确报告已过期/不存在时,才重新执行 `--no-wait --json` 生成新链接 ## 更新检查