Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
1845162
feat: add LARKSUITE_CLI_PROFILE session env with flag precedence
luozhixiong01 Jul 6, 2026
b1d4489
feat: add profile selection error subtypes and machine-readable fields
luozhixiong01 Jul 6, 2026
b6c19f5
feat: add IdentitySelection type for explainable credential selection
luozhixiong01 Jul 6, 2026
deb16ce
feat: unify credential selection with profile conflict detection
luozhixiong01 Jul 6, 2026
e6466b9
fix(credential): gate success-account direct-credential treatment on …
luozhixiong01 Jul 6, 2026
c24a382
test(credential): lock profile_secret_invalid against secret-bearing …
luozhixiong01 Jul 6, 2026
8666365
feat: surface credentialSource and directCredentialEnv in whoami
luozhixiong01 Jul 6, 2026
9b1f3fa
refactor: drop whoami suggestion field and IdentitySelection.Suggestion
luozhixiong01 Jul 6, 2026
c4f5022
docs: add profile selection help to profile and whoami commands
luozhixiong01 Jul 6, 2026
1d9f48b
docs: add profile selection entry hint to lark-shared skill
luozhixiong01 Jul 6, 2026
71521b9
fix(credential): add credential_source to config errors and report pr…
luozhixiong01 Jul 6, 2026
fe3d949
docs: forbid hollow identity promises in lark-shared profile hint
luozhixiong01 Jul 6, 2026
5a0f022
docs: broaden lark-shared trigger to profile selection and add sessio…
luozhixiong01 Jul 6, 2026
417777f
docs: trim lark-shared profile rule to lark-cli scope, drop agent-she…
luozhixiong01 Jul 6, 2026
4eb068f
docs: clarify whoami vs auth status boundary in lark-shared profile rule
luozhixiong01 Jul 6, 2026
70b611a
docs: use imperative one-line whoami vs auth status routing boundary
luozhixiong01 Jul 6, 2026
6aa8dec
feat: distinguish auth status from whoami identity in help and skill
luozhixiong01 Jul 6, 2026
d8f877f
docs: guide per-command LARKSUITE_CLI_PROFILE prefix for non-persiste…
luozhixiong01 Jul 7, 2026
4306f41
docs: point to auth status --json --verify for token validity checks
luozhixiong01 Jul 7, 2026
9cad3ca
docs: require auth status --json --verify for login/token checks
luozhixiong01 Jul 7, 2026
eabc855
test(credential): use recognized placeholders for secret fixtures
luozhixiong01 Jul 7, 2026
7c04a1a
docs(lark-shared): refine profile-selection guidance
luozhixiong01 Jul 7, 2026
beeeb71
fix(credential): propagate malformed-config error for explicit profile
luozhixiong01 Jul 7, 2026
ccc6dd3
feat(profile): distinguish saved default from effective identity
luozhixiong01 Jul 7, 2026
a340417
docs(lark-shared): write profile-selection guidance in Chinese
luozhixiong01 Jul 8, 2026
7cc881c
fix(credential): surface malformed config on the config-default path
luozhixiong01 Jul 13, 2026
ce00fdc
fix(credential): make profile arbitration explicit
luozhixiong01 Jul 14, 2026
0c8bf43
test: use recognized credential placeholders
luozhixiong01 Jul 14, 2026
7b32b22
docs(lark-shared): restore the trigger description to main's version
luozhixiong01 Jul 14, 2026
cf2af70
fix(credential): surface precise causes instead of flattened selectio…
luozhixiong01 Jul 15, 2026
76ee4cd
fix(config): pin saved-config commands to the saved default
luozhixiong01 Jul 15, 2026
f01f7ab
chore: guard empty profile source and document credential wire fields
luozhixiong01 Jul 15, 2026
fd9940c
fix(credential): surface the real keychain out-of-sync cause
luozhixiong01 Jul 15, 2026
dd42477
fix(credential): refuse cross-app tokens after a config edit
luozhixiong01 Jul 15, 2026
e28f917
fix(credential): keep policy mistakes and saved-config inspection off…
luozhixiong01 Jul 15, 2026
f6732c9
fix(profile): keep only the renamed default field in profile list
luozhixiong01 Jul 15, 2026
76bd47a
fix(credential): refuse mismatched tokens before any token work
luozhixiong01 Jul 15, 2026
6b80706
fix(credential): unforgeable AccountDirect reservation, gate aligned …
luozhixiong01 Jul 15, 2026
16ed512
test(credential): pin the TAT mint-then-cache happy path
luozhixiong01 Jul 15, 2026
51f3f07
docs(lark-shared): route pin/clear-session-identity intents from eval…
luozhixiong01 Jul 15, 2026
3a9cfdd
chore: use a recognized token placeholder and trim skill routing prose
luozhixiong01 Jul 15, 2026
01fdf2f
docs(lark-shared): format profile selection guidance
luozhixiong01 Jul 15, 2026
5442811
fix(auth): enforce app binding before token resolution
luozhixiong01 Jul 24, 2026
8bcb04c
test(auth): assert token dispatch call counts
luozhixiong01 Jul 24, 2026
754f471
test(auth): align token fixtures with account selection
luozhixiong01 Jul 24, 2026
0fef066
fix(auth): classify invalid sidecar policy
luozhixiong01 Jul 24, 2026
48b0ade
fix(profile): honor an explicit empty profile flag
luozhixiong01 Jul 24, 2026
8587203
test(sidecar): align fake provider with app binding
luozhixiong01 Jul 24, 2026
c86c348
fix(auth): reserve incomplete credential arbitration for env
luozhixiong01 Jul 24, 2026
4769b5c
docs(profile): clarify identity diagnostics heading
luozhixiong01 Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)

appInfoStub := &httpmock.Stub{
Method: http.MethodGet,
Expand Down Expand Up @@ -442,7 +442,7 @@ func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T)
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)

reg.Register(&httpmock.Stub{
Method: http.MethodGet,
Expand Down Expand Up @@ -485,6 +485,18 @@ type authScopesTokenResolver struct {
requests []credential.TokenSpec
}

type authTestAccountResolver struct {
appID string
}

func (r authTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
}

func newAuthTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, authTestAccountResolver{appID: appID}, tokenResolver, nil)
}

func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.requests = append(r.requests, req)
switch req.Type {
Expand Down
3 changes: 3 additions & 0 deletions cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
cmd := &cobra.Command{
Use: "status",
Short: "View current auth status",
Long: `Show OAuth user login, token validity, and granted scopes.
For token-validity checks, run lark-cli auth status --json --verify.
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
Expand Down
65 changes: 65 additions & 0 deletions cmd/auth/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,35 @@
package auth

import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"

extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
)

func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
cmd := NewCmdAuthStatus(nil, nil)
for _, want := range []string{
"OAuth user login",
"auth status --json --verify",
"not profile/app selection diagnostics",
"lark-cli whoami",
} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
}
}
}

func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
Expand Down Expand Up @@ -79,6 +99,51 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
}
}

type fixedStatusAccountResolver struct {
account *credential.Account
}

func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return r.account, nil
}

func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}

config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, config)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
nil,
nil,
).WithProfileFromFlag("tenant_a")

cmd := NewCmdAuth(f)
cmd.SetArgs([]string{"status", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status should use the selected built-in profile: %v", err)
}
if strings.Contains(stdout.String(), "credentials are provided externally") {
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
}
}

type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`
Expand Down
12 changes: 11 additions & 1 deletion cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ package cmd
import (
"errors"
"io"
"os"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/spf13/pflag"
)

Expand All @@ -26,5 +28,13 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
return cmdutil.InvocationContext{Profile: globals.Profile}, nil

profileFromFlag := fs.Changed("profile")
if !profileFromFlag {
globals.Profile = os.Getenv(envvars.CliProfile)
}
return cmdutil.InvocationContext{
Profile: globals.Profile,
ProfileFromFlag: profileFromFlag,
}, nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
61 changes: 60 additions & 1 deletion cmd/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

package cmd

import "testing"
import (
"testing"

"github.com/larksuite/cli/internal/envvars"
)

func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
Expand Down Expand Up @@ -70,3 +74,58 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}

func TestBootstrapProfileEnvFallback(t *testing.T) {
t.Run("flag wins over env", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_flag" {
t.Errorf("got %q, want tenant_flag", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("explicit empty flag clears env selection", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile=", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("env used when flag absent", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_env" {
t.Errorf("got %q, want tenant_env", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
t.Run("empty when neither set", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
}
84 changes: 83 additions & 1 deletion cmd/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
}
}

func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
cmd := NewCmdConfigShow(nil, nil)
if !strings.Contains(cmd.Short, "saved config") {
t.Errorf("config show short = %q, want saved config", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("config show help missing whoami route")
}
}

func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

Expand All @@ -106,6 +116,77 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
}
}

// config show promises "saved config, not current usage" (help + skill
// routing): the session profile (--profile / LARKSUITE_CLI_PROFILE) must not
// change what it shows.
func TestConfigShowRun_IgnoresSessionProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{
{Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu},
{Name: "tenant_b", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret-b"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}

f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Invocation.Profile = "tenant_b" // session selection must not leak in

if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"cli_a"`) || !strings.Contains(out, `"tenant_a"`) {
t.Fatalf("output = %s, want the saved default tenant_a/cli_a", out)
}
if strings.Contains(out, `"cli_b"`) {
t.Fatalf("output = %s, session profile tenant_b must not change saved-config view", out)
}
}

// engagedEnvStub simulates a fully engaged external credential provider.
type engagedEnvStub struct{}

func (engagedEnvStub) Name() string { return "env" }
func (engagedEnvStub) Priority() int { return 10 }
func (engagedEnvStub) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "cli_env", AppSecret: "your-password"}, nil // managed takeover
}
func (engagedEnvStub) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}

// config show inspects the SAVED config only, so the parent command's
// external-credential gate must not apply: even with a fully engaged direct
// env credential, `config show` still answers from the saved config.
func TestConfigShow_BypassesExternalCredentialGate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}

f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider([]extcred.Provider{engagedEnvStub{}}, nil, nil, nil)

cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"show"})
if err := cmd.Execute(); err != nil {
t.Fatalf("config show must bypass the external-credential gate: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"cli_a"`) {
t.Fatalf("output = %s, want the saved config shown", out)
}
}

func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
Expand Down Expand Up @@ -481,7 +562,8 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
// "show" is deliberately absent: it inspects the SAVED config only
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
Expand Down
16 changes: 14 additions & 2 deletions cmd/config/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,16 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *

cmd := &cobra.Command{
Use: "show",
Short: "Show current configuration",
Short: "Show saved config",
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
// Override parent's RequireBuiltinCredentialProvider check: this
// command reads the SAVED config only (its own help promises "saved
// config, not current usage"), so the currently effective credential
// source — external or otherwise — must not gate it.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
Expand All @@ -53,7 +62,10 @@ func configShowRun(opts *ConfigShowOptions) error {
if config == nil || len(config.Apps) == 0 {
return core.NotConfiguredError()
}
app := config.CurrentAppConfig(f.Invocation.Profile)
// Saved config only: the session profile (--profile / LARKSUITE_CLI_PROFILE)
// must not change what this command shows — the help and skill routing
// promise "saved config, not current usage" (use whoami for that).
app := config.CurrentAppConfig("")
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
}
Expand Down
14 changes: 13 additions & 1 deletion cmd/event/consume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,20 @@ func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
return nil, errors.New("backend unavailable")
}

type eventTestAccountResolver struct {
appID string
}

func (r eventTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}

func newEventTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, eventTestAccountResolver{appID: appID}, tokenResolver, nil)
}

func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
}

func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/event/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,
Expand Down
Loading
Loading