Compare commits

...

23 Commits

Author SHA1 Message Date
luozhixiong
425122e611 fix(credential): propagate malformed-config error for explicit profile
When an explicit profile was requested and LoadMultiAppConfig failed, the
error was discarded and every failure reported as profile_not_found,
masking a real config problem (e.g. malformed file) behind a misleading
"run profile list" hint. Propagate the underlying error when it is a
malformed-config failure (errors.Is ErrMalformedConfig) so errors.Is /
errors.Unwrap keep working, mirroring the no-profile branch. An absent
config is not malformed and still yields the friendly profile_not_found.
2026-07-07 15:33:44 +08:00
luozhixiong
80fade789b docs(lark-shared): refine profile-selection guidance
Clarify that --profile and LARKSUITE_CLI_PROFILE accept either a profile
name or an app_id, keep the effective-identity vs OAuth-token boundary
(whoami vs auth status --json --verify), and note not to set direct
app-credential env vars unless direct credentials are provided.
2026-07-07 14:47:16 +08:00
luozhixiong
cc5cec32dc test(credential): use recognized placeholders for secret fixtures
Replace credential-shaped literals in whoami and selection tests with
placeholder values recognized by the public-content quality gate
(test-secret / your-secret / your-password / your-access-token), so the
deterministic public-content scan does not flag test fixtures as generic
credentials. No behavioral change; the fixtures are only compared for
non-leakage and identity arbitration.
2026-07-07 14:47:16 +08:00
luozhixiong
251de0f609 docs: require auth status --json --verify for login/token checks 2026-07-07 11:28:13 +08:00
luozhixiong
d7156b327e docs: point to auth status --json --verify for token validity checks 2026-07-07 11:27:34 +08:00
luozhixiong
a3c97572b6 docs: guide per-command LARKSUITE_CLI_PROFILE prefix for non-persistent shells 2026-07-07 11:22:54 +08:00
luozhixiong
978b711663 feat: distinguish auth status from whoami identity in help and skill 2026-07-06 20:53:24 +08:00
luozhixiong
d22d8473a2 docs: use imperative one-line whoami vs auth status routing boundary 2026-07-06 20:40:58 +08:00
luozhixiong
4c94bdca27 docs: clarify whoami vs auth status boundary in lark-shared profile rule 2026-07-06 20:32:39 +08:00
luozhixiong
29d5b95971 docs: trim lark-shared profile rule to lark-cli scope, drop agent-shell env mechanics 2026-07-06 20:23:34 +08:00
luozhixiong
fec11926be docs: broaden lark-shared trigger to profile selection and add session-env persistence guidance 2026-07-06 20:16:01 +08:00
luozhixiong
816a6939f2 docs: forbid hollow identity promises in lark-shared profile hint 2026-07-06 20:02:42 +08:00
luozhixiong
04edca5717 fix(credential): add credential_source to config errors and report profile_secret_invalid for broken default secret 2026-07-06 19:27:04 +08:00
luozhixiong
a90715f5a9 docs: add profile selection entry hint to lark-shared skill 2026-07-06 18:13:21 +08:00
luozhixiong
f13cee23a2 docs: add profile selection help to profile and whoami commands 2026-07-06 18:10:32 +08:00
luozhixiong
e3cea8936e refactor: drop whoami suggestion field and IdentitySelection.Suggestion
whoami reports facts about the effective identity; it should not
proactively push profile-switching guidance at agents. That guidance
lives in `profile --help` / the lark-shared skill, and failure recovery
already lives in error hints. Remove the now-unused Suggestion field
from IdentitySelection and its only setter/consumer.
2026-07-06 18:05:24 +08:00
luozhixiong
f91e8bec54 feat: surface credentialSource and directCredentialEnv in whoami 2026-07-06 18:01:02 +08:00
luozhixiong
0c5aae42b4 test(credential): lock profile_secret_invalid against secret-bearing cause
Add a case where the underlying account-resolution error itself contains a
secret marker, proving doResolveAccount's drop-the-cause design (§5.1) holds
beyond the existing noop-keychain (empty-error) test, including across the
full errors.Unwrap chain.
2026-07-06 17:52:03 +08:00
luozhixiong
bf8c41225d fix(credential): gate success-account direct-credential treatment on env provider
Mirror the env-incomplete block-path guard on the success-account path so a
non-env extension provider (e.g. sidecar, Priority 0) that returns an account
wins outright instead of being misreported as a direct-credential env account.
This restores pre-diff behavior for such providers: no profile arbitration, no
spurious profile_app_credential_conflict, and DirectCredentialEnv.Present stays
false when no direct env vars are set. Env matrix states are unchanged.

Add TestSelection_NonEnvExtensionProviderWinsOverProfile as a regression guard.
2026-07-06 17:42:16 +08:00
luozhixiong
0c51792f77 feat: unify credential selection with profile conflict detection 2026-07-06 17:30:51 +08:00
luozhixiong
e30869d015 feat: add IdentitySelection type for explainable credential selection 2026-07-06 17:17:40 +08:00
luozhixiong
a3e78daff9 feat: add profile selection error subtypes and machine-readable fields
Declares the 5 stable error subtypes (4 config + 1 validation) and the
ConfigError/ValidationError extension fields the profile-selection
credential core (Task 4) will produce, plus builder-chain and wire-pin
tests pinning their shape.
2026-07-06 17:12:56 +08:00
luozhixiong
8abd39b18e feat: add LARKSUITE_CLI_PROFILE session env with flag precedence
Add LARKSUITE_CLI_PROFILE env var and make BootstrapInvocationContext
fall back to it when --profile is empty, so downstream credential
resolution sees the correct profile. Also track whether the resolved
profile came from the flag or the env fallback via a new
InvocationContext.ProfileFromFlag field, needed by a later task to
report the correct credential source.
2026-07-06 17:06:12 +08:00
20 changed files with 1301 additions and 49 deletions

View File

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

View File

@@ -6,6 +6,7 @@ package auth
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
@@ -13,6 +14,20 @@ import (
"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,

View File

@@ -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"
)
@@ -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 := globals.Profile != ""
if !profileFromFlag {
globals.Profile = os.Getenv(envvars.CliProfile)
}
return cmdutil.InvocationContext{
Profile: globals.Profile,
ProfileFromFlag: profileFromFlag,
}, nil
}

View File

@@ -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"})
@@ -70,3 +74,45 @@ 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("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")
}
})
}

View File

@@ -14,6 +14,13 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
Long: `Profiles are named app identities managed by lark-cli.
Profile selection:
--profile <name> Use a profile for this command only.
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
lark-cli whoami --json Show which identity is actually used.
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.`,
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetTips(cmd, []string{

View File

@@ -627,6 +627,19 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
})
}
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
// per-invocation flag and session-scoped env var for selecting a profile, so
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
func TestProfileHelpHasSelectionSection(t *testing.T) {
cmd := NewCmdProfile(nil)
if !strings.Contains(cmd.Long, "Profile selection:") {
t.Errorf("profile --help missing Profile selection section")
}
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
}
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {

View File

@@ -10,6 +10,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
@@ -33,6 +34,15 @@ type whoamiResult struct {
TokenStatus string `json:"tokenStatus"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
// credential.IdentitySelection computed during resolution (not re-inferred
// here). CredentialSource can be empty ("") on the non-env
// extension-provider path (e.g. sidecar mode), where no selection kind
// applies; this is a documented, valid state, not an error.
CredentialSource string `json:"credentialSource"`
Explicit bool `json:"explicit"`
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
}
// delegatedUser is the user a user-identity acts on behalf of.
@@ -58,6 +68,10 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
The JSON output includes credentialSource, appId, brand, and whether direct app credential
env is present and matches the selected profile.`,
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
@@ -97,7 +111,17 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
// Read the cached selection computed during resolution; never re-infer it
// here. A resolution failure (e.g. under a non-env extension provider that
// doesn't populate a selection) degrades to the zero value rather than
// regressing whoami's own error/diagnostic path above.
var selection credential.IdentitySelection
if f.Credential != nil {
if sel, err := f.Credential.Selection(ctx); err == nil {
selection = sel
}
}
res := buildResult(cfg, as, source, diag, selection)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
@@ -122,18 +146,23 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
// selection is the cached credential.IdentitySelection from resolution; it is
// read as-is, never recomputed.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
CredentialSource: string(selection.Source),
Explicit: selection.Explicit(),
DirectCredentialEnv: selection.DirectCredentialEnv,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.

View File

@@ -15,10 +15,13 @@ import (
"github.com/larksuite/cli/errs"
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/identitydiag"
"github.com/larksuite/cli/internal/keychain"
)
func TestResolveSource(t *testing.T) {
@@ -52,7 +55,7 @@ func TestBuildResult_UserValid(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -77,7 +80,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
if r.Available {
t.Fatalf("available = true, want false")
@@ -100,7 +103,7 @@ func TestBuildResult_BotReady(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag)
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -121,7 +124,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
if r.Available {
t.Fatalf("available = true, want false")
@@ -318,3 +321,94 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
// plaintext secret, so no keychain lookup is actually required.
type noopWhoamiKeychain struct{}
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
// credentialSourceSecret is the profile secret written to config for
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
// (security §5.1).
const credentialSourceSecret = "test-secret"
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
// fallback (not --profile), so Selection().Source resolves to
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
// app-credential env vars present.
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
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(credentialSourceSecret),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
cred.WithProfile("tenant_a", false) // fromFlag=false -> env:LARKSUITE_CLI_PROFILE
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
// from the cached credential.IdentitySelection (Task 6): credentialSource,
// explicit, and directCredentialEnv. whoami must read the cached selection
// as-is, not re-infer it.
func TestWhoamiIncludesCredentialSource(t *testing.T) {
f, out := profileSelectionFactory(t)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw := out.String()
if strings.Contains(raw, credentialSourceSecret) {
t.Fatalf("whoami output leaked the profile secret: %s", raw)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
}
if got.CredentialSource != string(credential.SourceEnvProfile) {
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
}
if !got.Explicit {
t.Fatalf("explicit = false, want true")
}
if got.DirectCredentialEnv.Present {
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
}
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
}
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
}
}

View File

@@ -136,6 +136,77 @@ func TestConfigError_MarshalJSON(t *testing.T) {
}
}
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
WithProfile("work").
WithAppID("cli_abc").
WithCredentialSource("flag:--profile")
b, err := json.Marshal(ce)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"config"`,
`"subtype":"app_credential_incomplete"`,
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
`"profile":"work"`,
`"app_id":"cli_abc"`,
`"credential_source":"flag:--profile"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset fields must not appear on the wire.
empty := NewConfigError(SubtypeProfileNotFound, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"missing_keys"`, `"profile"`, `"app_id"`, `"credential_source"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
b, err := json.Marshal(ve)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"validation"`,
`"subtype":"profile_app_credential_conflict"`,
`"profile_app_id":"cli_profile"`,
`"env_app_id":"cli_env"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset conflict fields must not appear on the wire.
empty := NewValidationError(SubtypeInvalidArgument, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestNetworkError_MarshalJSON(t *testing.T) {
ne := &NetworkError{
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},

View File

@@ -12,8 +12,9 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
)
// CategoryAuthentication subtypes
@@ -41,9 +42,13 @@ const (
// CategoryConfig subtypes
const (
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
)
// CategoryNetwork subtypes

View File

@@ -61,9 +61,11 @@ type TypedError interface {
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"`
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
ProfileAppID string `json:"profile_app_id,omitempty"`
EnvAppID string `json:"env_app_id,omitempty"`
Cause error `json:"-"`
}
// InvalidParam is one structured validation diagnostic: the parameter that
@@ -150,6 +152,12 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
return e
}
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
e.ProfileAppID = profileAppID
e.EnvAppID = envAppID
return e
}
// =========================== AuthenticationError =============================
// AuthenticationError is the typed error for CategoryAuthentication.
@@ -315,8 +323,17 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
// intentionally not serialized.
type ConfigError struct {
Problem
Field string `json:"field,omitempty"`
Cause error `json:"-"`
Field string `json:"field,omitempty"`
MissingKeys []string `json:"missing_keys,omitempty"`
Profile string `json:"profile,omitempty"`
AppID string `json:"app_id,omitempty"`
// CredentialSource is the machine-readable App/credential selection source
// that produced this config error (e.g. "flag:--profile",
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
// profile_not_found and no_active_profile (spec §5) so an agent can branch
// on how the identity was (or was not) chosen. It is never a secret.
CredentialSource string `json:"credential_source,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
@@ -370,6 +387,29 @@ func (e *ConfigError) WithField(field string) *ConfigError {
return e
}
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
e.MissingKeys = slices.Clone(keys)
return e
}
func (e *ConfigError) WithProfile(name string) *ConfigError {
e.Profile = name
return e
}
func (e *ConfigError) WithAppID(appID string) *ConfigError {
e.AppID = appID
return e
}
// WithCredentialSource records the machine-readable credential-selection source
// on the wire (snake_case credential_source). The value is an enum string
// (e.g. "flag:--profile", "config"), never a secret.
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
e.CredentialSource = source
return e
}
func (e *ConfigError) WithCause(cause error) *ConfigError {
e.Cause = cause
return e

View File

@@ -643,3 +643,29 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
}
})
}
// ======================= Profile selection error subtypes =======================
func TestConfigErrorProfileFields(t *testing.T) {
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID").
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
p, ok := errs.ProblemOf(e)
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
t.Fatalf("subtype mismatch: %+v", p)
}
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
t.Errorf("missing_keys not set: %v", e.MissingKeys)
}
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
t.Errorf("credential_source not set: %q", e.CredentialSource)
}
}
func TestValidationErrorProfileConflict(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
}
}

View File

@@ -27,6 +27,11 @@ import (
// In tests, replace any field to stub out external dependencies.
type InvocationContext struct {
Profile string
// ProfileFromFlag is true when Profile was set via the --profile flag,
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
// (or neither was set). Downstream credential resolution uses this to
// report the correct profile source.
ProfileFromFlag bool
}
type Factory struct {

View File

@@ -61,10 +61,11 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
ProfileFromFlag: inv.ProfileFromFlag,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
@@ -162,10 +163,11 @@ func buildSDKTransport() http.RoundTripper {
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
Keychain func() keychain.KeychainAccess
Profile string
ProfileFromFlag bool
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
@@ -178,5 +180,6 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// depend on. enrichUserInfo failures are already non-fatal (the
// provider clears unverified identity fields), so silencing the
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient).
WithProfile(deps.Profile, deps.ProfileFromFlag)
}

View File

@@ -9,13 +9,21 @@ import (
"fmt"
"io"
"net/http"
"os"
"sync"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// directCredentialProviderName is the Name() of the env provider, the source
// of direct app credentials (LARKSUITE_CLI_APP_ID / _APP_SECRET). Only its
// incomplete blocks map to app_credential_incomplete (spec §3 step 1).
const directCredentialProviderName = "env"
// DefaultAccountResolver is implemented by the default account provider.
type DefaultAccountResolver interface {
ResolveAccount(ctx context.Context) (*Account, error)
@@ -136,10 +144,18 @@ type CredentialProvider struct {
httpClient func() (*http.Client, error)
warnOut io.Writer
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE).
// profileFromFlag discriminates the source for the reported selection.
profile string
profileFromFlag bool
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
// selection is the explainable credential-selection result, populated by
// doResolveAccount under accountOnce. It never carries a secret (§5.1).
selection IdentitySelection
hintOnce sync.Once
hint *IdentityHint
@@ -161,6 +177,15 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
return p
}
// WithProfile records the active profile and whether it came from the
// --profile flag (as opposed to the LARKSUITE_CLI_PROFILE env fallback).
// It governs credential arbitration and the reported selection source.
func (p *CredentialProvider) WithProfile(profile string, fromFlag bool) *CredentialProvider {
p.profile = profile
p.profileFromFlag = fromFlag
return p
}
// ResolveAccount resolves app credentials. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
// Subsequent calls return the cached result regardless of their context.
@@ -172,40 +197,273 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
return p.account, p.accountErr
}
// doResolveAccount arbitrates the credential/App selection per the spec
// resolution order (§3): env-partial → profile → env-complete → config default.
// It populates p.selection (no secret; §5.1) and p.selectedSource on every
// success path.
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
// Step 1 (spec §3): consult the extension providers. The env provider is
// the "direct app credential" source. An incomplete direct credential
// (only APP_ID or only APP_SECRET set) short-circuits to
// app_credential_incomplete regardless of the active profile.
var envAcct *Account
var envSource extensionTokenSource
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
var blockErr *extcred.BlockError
// Only the env (direct-credential) provider maps an incomplete
// block to app_credential_incomplete. Other providers' blocks
// propagate unchanged so they still stop the chain (§3 step 1
// is specifically about direct app credential env vars).
if errors.As(err, &blockErr) && prov.Name() == directCredentialProviderName {
if missing := missingDirectCredentialKeys(); len(missing) > 0 {
return nil, errs.NewConfigError(errs.SubtypeAppCredentialIncomplete,
"direct app credential is incomplete").
WithMissingKeys(missing...).
WithHint("set both %s and %s, or unset both and use --profile / a config default.",
envvars.CliAppID, envvars.CliAppSecret)
}
// Block for a reason other than incompleteness (e.g. an
// invalid identity/strict-mode value); preserve prior behavior.
return nil, err
}
return nil, err
}
if acct != nil {
internal := convertAccount(acct)
source := extensionTokenSource{provider: prov}
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
// Only the env (direct-credential) provider feeds profile
// arbitration / conflict detection / DirectCredentialEnv reporting.
// This mirrors the block-path guard above. A non-env extension
// provider (e.g. sidecar) is NOT a direct-credential env account:
// it wins outright here, returning its account + token source
// unchanged (pre-diff behavior), without being misreported as a
// direct env credential (§4.2: Present = direct env vars actually
// set) or triggering a spurious profile_app_credential_conflict.
if prov.Name() != directCredentialProviderName {
internal := convertAccount(acct)
source := extensionTokenSource{provider: prov}
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
p.selectedSource = source
return internal, nil
}
p.selectedSource = source
return internal, nil
envAcct = convertAccount(acct)
envSource = extensionTokenSource{provider: prov}
break
}
}
if p.defaultAcct != nil {
// Step 2 (spec §3): an explicit profile was requested.
if p.profile != "" {
multi, loadErr := core.LoadMultiAppConfig()
if errors.Is(loadErr, core.ErrMalformedConfig) {
// A malformed config must not be masked as profile_not_found (which
// would tell the user to run `profile list` and hide a real config
// problem). Pass the underlying error through unchanged so
// errors.Is / errors.Unwrap keep working. An absent config is not
// malformed and still falls through to the friendly
// profile_not_found below, since the requested profile cannot exist.
return nil, loadErr
}
var app *core.AppConfig
if loadErr == nil && multi != nil {
app = multi.FindApp(p.profile)
}
if app == nil {
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
"profile %q not found", p.profile).
WithProfile(p.profile).
WithCredentialSource(string(p.profileSource())).
WithHint("run `lark-cli profile list` to see available profiles.")
}
if envAcct != nil {
// E == complete: the direct env app_id must match the profile.
if app.AppId != envAcct.AppID {
return nil, errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
"profile %q app_id does not match %s", p.profile, envvars.CliAppID).
WithProfileAppConflict(app.AppId, envAcct.AppID).
WithHint("unset %s/%s, or select a profile whose app_id matches the environment.",
envvars.CliAppID, envvars.CliAppSecret)
}
p.selection = IdentitySelection{
Source: p.profileSource(),
DirectCredentialEnv: DirectCredentialEnv{
Present: true,
Keys: presentDirectCredentialKeys(),
AppID: envAcct.AppID,
Matched: true,
},
}
} else {
p.selection = IdentitySelection{
Source: p.profileSource(),
DirectCredentialEnv: DirectCredentialEnv{Present: false},
}
}
// Resolve the profile's own (keychain-backed) credential locally.
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
// SECURITY (§5.1): generic message — never embed the underlying
// error or any secret material.
p.selection = IdentitySelection{}
return nil, errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
"profile %q credential could not be resolved locally", p.profile).
WithProfile(p.profile).
WithAppID(app.AppId).
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
}
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
// Step 3 (spec §3): no explicit profile — direct env credential wins.
if envAcct != nil {
if err := p.enrichUserInfo(ctx, envAcct, envSource); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", envSource.Name(), err)
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
envAcct.UserOpenId = ""
envAcct.UserName = ""
}
p.selectedSource = envSource
p.selection = IdentitySelection{
Source: SourceEnvAppID,
DirectCredentialEnv: DirectCredentialEnv{
Present: true,
Keys: presentDirectCredentialKeys(),
AppID: envAcct.AppID,
},
}
return envAcct, nil
}
// No direct env credential and no profile → the config default.
if p.defaultAcct != nil {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
// The config default failed to resolve. Distinguish (spec §3 step
// 3.2): a default profile that EXISTS (has an app_id) but whose
// secret cannot be resolved locally is a profile_secret_invalid —
// "identity is configured, its secret is broken" is more actionable
// than "no active profile". Only when there is genuinely no usable
// default profile do we report no_active_profile. Other typed
// failures (e.g. a specific config error) pass through unchanged.
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype == errs.SubtypeNotConfigured {
if name, appID, ok := defaultProfileIdentity(); ok {
// SECURITY (§5.1): generic message — never embed the
// underlying error or any secret material. app_id is
// plaintext and safe to echo.
return nil, errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
"profile %q credential could not be resolved locally", name).
WithProfile(name).
WithAppID(appID).
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
}
return nil, errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
WithCredentialSource(noActiveProfileCredentialSource).
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
}
return nil, err
}
multi, _ := core.LoadMultiAppConfig()
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
p.selection = IdentitySelection{Source: selectionSourceForDefault(multi)}
return acct, nil
}
return nil, core.NotConfiguredError()
}
// profileSource reports the credential source kind for a profile-backed
// selection, discriminating the --profile flag from the env fallback.
func (p *CredentialProvider) profileSource() CredentialSourceKind {
if p.profileFromFlag {
return SourceFlagProfile
}
return SourceEnvProfile
}
// noActiveProfileCredentialSource is the credential_source reported on the
// no_active_profile error. Spec §5 fixes this to the literal "config": there is
// no resolved default profile at all, so the more specific config:currentApp /
// config:firstApp source values (used on successful config-default selections)
// would be misleading. It is an enum string, never a secret.
const noActiveProfileCredentialSource = "config"
// defaultProfileIdentity reports the config default profile's display name and
// app_id when a usable default profile actually EXISTS (currentApp > firstApp
// resolves to an app with a non-empty app_id). It never touches the keychain or
// any secret, so it can distinguish "default profile exists but its secret is
// broken" (→ profile_secret_invalid) from "no usable default profile at all"
// (→ no_active_profile), without risking a secret leak (§5.1).
func defaultProfileIdentity() (name, appID string, ok bool) {
multi, err := core.LoadMultiAppConfig()
if err != nil || multi == nil {
return "", "", false
}
app := multi.CurrentAppConfig("")
if app == nil || app.AppId == "" {
return "", "", false
}
return app.ProfileName(), app.AppId, true
}
// selectionSourceForDefault reports whether the config default resolved to the
// explicit currentApp or fell back to the first app (spec §3 step 3.2).
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
if multi != nil && multi.CurrentApp != "" {
return SourceConfigCurrentApp
}
return SourceConfigFirstApp
}
// missingDirectCredentialKeys returns the NAMES (never values) of the direct
// app credential env vars that are absent. Used only when the env provider
// blocks, to map an incomplete direct credential to app_credential_incomplete.
func missingDirectCredentialKeys() []string {
var missing []string
if os.Getenv(envvars.CliAppID) == "" {
missing = append(missing, envvars.CliAppID)
}
if os.Getenv(envvars.CliAppSecret) == "" {
missing = append(missing, envvars.CliAppSecret)
}
return missing
}
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
func presentDirectCredentialKeys() []string {
var keys []string
if os.Getenv(envvars.CliAppID) != "" {
keys = append(keys, envvars.CliAppID)
}
if os.Getenv(envvars.CliAppSecret) != "" {
keys = append(keys, envvars.CliAppSecret)
}
return keys
}
// Selection resolves the account (once) and returns the cached, secret-free
// explanation of how the credential/App was selected. It mirrors
// selectedCredentialSource: resolve-then-return.
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
if _, err := p.ResolveAccount(ctx); err != nil {
return IdentitySelection{}, err
}
return p.selection, nil
}
// enrichUserInfo resolves user identity when extension provides a UAT.
// If UAT is available, user_info API call is mandatory (security: verify token validity).
// If no UAT from extension, falls back to provider-supplied OpenID.

View File

@@ -0,0 +1,554 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential_test
import (
"context"
"errors"
"fmt"
"os"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/keychain"
)
func asConfigError(t *testing.T, err error) *errs.ConfigError {
t.Helper()
var ce *errs.ConfigError
if !errors.As(err, &ce) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
return ce
}
func asValidationError(t *testing.T, err error) *errs.ValidationError {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}
// secretValue is the profile secret written to config. It must NEVER appear in
// any error message or IdentitySelection (security §5.1).
const secretValue = "your-secret"
// envSecretValue is the direct env app secret. Same no-leak guarantee.
const envSecretValue = "your-password"
// writeConfigTenantA writes a config with a single profile "tenant_a" (app_id
// "cli_a"). The secret is a plaintext secret stored in config, which resolves
// locally without a keychain lookup.
func writeConfigTenantA(t *testing.T) {
t.Helper()
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(secretValue),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
}
// writeConfigTenantABroken writes tenant_a with a keychain-backed secret ref
// that cannot be resolved (noop keychain returns empty), so profile secret
// resolution fails locally.
func writeConfigTenantABroken(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// A keychain SecretRef whose key does NOT match app_id cli_a. Local secret
// resolution fails (ValidateSecretKeyMatch), exercising profile_secret_invalid.
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:wrong_key"}},
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
}
func newProvider(t *testing.T, profile string, fromFlag bool) *credential.CredentialProvider {
t.Helper()
ep := &envprovider.Provider{}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, profile)
cp := credential.NewCredentialProvider([]extcred.Provider{ep}, defaultAcct, nil, nil)
cp.WithProfile(profile, fromFlag)
return cp
}
// assertNoSecretLeak fails if any secret value appears in the given strings.
func assertNoSecretLeak(t *testing.T, where string, vals ...string) {
t.Helper()
for _, v := range vals {
if v == "" {
continue
}
if strings.Contains(v, secretValue) {
t.Errorf("%s leaked profile secret: %q", where, v)
}
if strings.Contains(v, envSecretValue) {
t.Errorf("%s leaked env secret: %q", where, v)
}
}
}
func subtypeOf(t *testing.T, err error) errs.Subtype {
t.Helper()
if err == nil {
t.Fatalf("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not a typed problem: %v", err)
}
return p.Subtype
}
// State #2: P none, E none, C none -> no_active_profile.
func TestSelection_State2_NoActiveProfile(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // empty dir -> no config
cp := newProvider(t, "", false)
sel, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeNoActiveProfile {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeNoActiveProfile)
}
// Defect 1 (spec §5): no_active_profile must carry credential_source=config.
ce := asConfigError(t, err)
if ce.CredentialSource != "config" {
t.Errorf("credential_source = %q, want %q", ce.CredentialSource, "config")
}
assertNoSecretLeak(t, "state2", err.Error(), string(sel.Source))
}
// Config-default profile with a broken secret: P none, E none, C present but the
// default profile's keychain secret ref is corrupted. Per spec §3 step 3.2 this
// must be profile_secret_invalid (the identity IS configured, only its secret is
// broken) — NOT no_active_profile (which is reserved for "no usable default").
func TestSelection_ConfigDefaultBrokenSecret_ProfileSecretInvalid(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
writeConfigTenantABroken(t) // CurrentApp = tenant_a (app_id cli_a), broken keychain ref
cp := newProvider(t, "", false)
_, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
}
ce := asConfigError(t, err)
if ce.Profile != "tenant_a" {
t.Errorf("profile = %q, want tenant_a", ce.Profile)
}
if ce.AppID != "cli_a" {
t.Errorf("app_id = %q, want cli_a", ce.AppID)
}
// §5.1: generic message, no cause, no secret anywhere.
if errors.Unwrap(ce) != nil {
t.Errorf("profile_secret_invalid must not attach a cause, got %v", errors.Unwrap(ce))
}
assertNoSecretLeak(t, "config-default-broken", ce.Message, ce.Hint, ce.AppID)
}
// Explicit profile requested but the config file is malformed. The load error
// must be propagated (errors.Is ErrMalformedConfig) rather than masked as
// profile_not_found, which would hide a real config problem and misdirect the
// user to `profile list`. An absent config is separately still profile_not_found.
func TestSelection_ExplicitProfile_MalformedConfig_PropagatesError(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := os.MkdirAll(core.GetConfigDir(), 0o700); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(core.GetConfigPath(), []byte("{ this is not valid json"), 0o600); err != nil {
t.Fatalf("write malformed config: %v", err)
}
cp := newProvider(t, "tenant_a", true)
_, err := cp.Selection(context.Background())
if err == nil {
t.Fatalf("expected error for malformed config, got nil")
}
if !errors.Is(err, core.ErrMalformedConfig) {
t.Fatalf("malformed config error not propagated: %v", err)
}
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype == errs.SubtypeProfileNotFound {
t.Fatalf("malformed config masked as profile_not_found")
}
}
// State #3: P none, E partial (only APP_ID) -> app_credential_incomplete.
func TestSelection_State3_EnvPartial(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliAppSecret, "")
writeConfigTenantA(t)
cp := newProvider(t, "", false)
_, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeAppCredentialIncomplete {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeAppCredentialIncomplete)
}
prob, _ := errs.ProblemOf(err)
ce := asConfigError(t, err)
if !slices.Contains(ce.MissingKeys, envvars.CliAppSecret) {
t.Errorf("missing_keys = %v, want to contain %q", ce.MissingKeys, envvars.CliAppSecret)
}
// missing_keys must be NAMES only, never values.
for _, k := range ce.MissingKeys {
if strings.Contains(k, envSecretValue) || strings.Contains(k, secretValue) {
t.Errorf("missing_keys contains a value, not a name: %q", k)
}
}
assertNoSecretLeak(t, "state3", prob.Message, prob.Hint)
}
// State #4: P none, E complete -> env:LARKSUITE_CLI_APP_ID.
func TestSelection_State4_EnvComplete(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliAppSecret, envSecretValue)
writeConfigTenantA(t)
cp := newProvider(t, "", false)
sel, err := cp.Selection(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sel.Source != credential.SourceEnvAppID {
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceEnvAppID)
}
if !sel.DirectCredentialEnv.Present {
t.Errorf("DirectCredentialEnv.Present = false, want true")
}
assertNoSecretLeak(t, "state4", string(sel.Source), sel.DirectCredentialEnv.AppID)
assertNoSecretLeak(t, "state4-keys", sel.DirectCredentialEnv.Keys...)
}
// State #5: P valid, E none -> flag:--profile (fromFlag) source.
func TestSelection_State5_ProfileOnly(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
writeConfigTenantA(t)
cp := newProvider(t, "tenant_a", true)
sel, err := cp.Selection(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sel.Source != credential.SourceFlagProfile {
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceFlagProfile)
}
if sel.DirectCredentialEnv.Present {
t.Errorf("DirectCredentialEnv.Present = true, want false")
}
assertNoSecretLeak(t, "state5", string(sel.Source))
}
// State #5b: P valid from env (not flag) -> env:LARKSUITE_CLI_PROFILE source.
func TestSelection_State5_ProfileFromEnv(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
writeConfigTenantA(t)
cp := newProvider(t, "tenant_a", false)
sel, err := cp.Selection(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sel.Source != credential.SourceEnvProfile {
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceEnvProfile)
}
}
// State #6: P missing (nonexistent), E complete -> profile_not_found.
func TestSelection_State6_ProfileNotFound(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliAppSecret, envSecretValue)
writeConfigTenantA(t)
cp := newProvider(t, "does_not_exist", true)
sel, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeProfileNotFound {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileNotFound)
}
prob, _ := errs.ProblemOf(err)
// Defect 1 (spec §5): profile_not_found must carry the credential_source that
// named the profile — here the --profile flag.
ce := asConfigError(t, err)
if ce.CredentialSource != string(credential.SourceFlagProfile) {
t.Errorf("credential_source = %q, want %q", ce.CredentialSource, credential.SourceFlagProfile)
}
assertNoSecretLeak(t, "state6", err.Error(), prob.Hint, string(sel.Source))
}
// State #7: P valid but secret broken, E none -> profile_secret_invalid.
func TestSelection_State7_ProfileSecretInvalid(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
writeConfigTenantABroken(t)
cp := newProvider(t, "tenant_a", true)
_, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
}
ce := asConfigError(t, err)
if ce.Profile != "tenant_a" {
t.Errorf("profile = %q, want tenant_a", ce.Profile)
}
if ce.AppID != "cli_a" {
t.Errorf("app_id = %q, want cli_a", ce.AppID)
}
assertNoSecretLeak(t, "state7", ce.Message, ce.Hint)
}
// secretMarkerValue is a distinctive string used to prove that the
// profile_secret_invalid path drops the underlying error entirely, even when
// that underlying error's own message CONTAINS a secret. Unlike
// writeConfigTenantABroken (whose noop-keychain failure is a harmless empty
// error), this uses a custom DefaultAccountResolver whose error text embeds
// the marker, closing the gap where a leak could hide in a cause chain that
// happens to be empty in the noop-keychain case.
const secretMarkerValue = "your-access-token"
// leakingSecretResolver is a DefaultAccountResolver stub whose ResolveAccount
// fails with an error whose message contains secretMarkerValue, simulating a
// real keychain/secret-resolution failure that echoes back sensitive material
// (e.g. a keychain library including the attempted secret in its error text).
type leakingSecretResolver struct{}
func (leakingSecretResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) {
return nil, fmt.Errorf("keychain decode failed for secret %s", secretMarkerValue)
}
// State #7 (secret-bearing underlying error): P valid, but the underlying
// account/secret resolution fails with an error that itself contains a
// secret. This locks the §5.1 design: doResolveAccount emits a generic
// profile_secret_invalid ConfigError WITHOUT attaching the underlying cause,
// so a secret embedded in that underlying error can never surface through
// err.Error(), Message, Hint, the unwrapped cause chain, or Selection().
func TestSelection_State7_UnderlyingErrorContainingSecret_NotLeaked(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
writeConfigTenantA(t) // profile "tenant_a" exists with app_id "cli_a"
ep := &envprovider.Provider{}
cp := credential.NewCredentialProvider([]extcred.Provider{ep}, leakingSecretResolver{}, nil, nil)
cp.WithProfile("tenant_a", true)
sel, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeProfileSecretInvalid {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileSecretInvalid)
}
ce := asConfigError(t, err)
if ce.Profile != "tenant_a" {
t.Errorf("profile = %q, want tenant_a", ce.Profile)
}
if ce.AppID != "cli_a" {
t.Errorf("app_id = %q, want cli_a", ce.AppID)
}
// Walk the full unwrap chain. This is the assertion that would catch a
// regression where the profile_secret_invalid branch starts attaching the
// underlying error via WithCause: if it did, this loop would find the
// marker in a wrapped link even though err.Error()/Message/Hint (which
// only reflect the top-level ConfigError, not the chain) might look clean.
for cur := error(ce); cur != nil; cur = errors.Unwrap(cur) {
if strings.Contains(cur.Error(), secretMarkerValue) {
t.Errorf("cause chain leaked secret marker: %v", cur)
}
}
if strings.Contains(err.Error(), secretMarkerValue) {
t.Errorf("err.Error() leaked secret marker: %q", err.Error())
}
if strings.Contains(ce.Message, secretMarkerValue) {
t.Errorf("Message leaked secret marker: %q", ce.Message)
}
if strings.Contains(ce.Hint, secretMarkerValue) {
t.Errorf("Hint leaked secret marker: %q", ce.Hint)
}
if strings.Contains(string(sel.Source), secretMarkerValue) {
t.Errorf("Selection.Source leaked secret marker: %q", sel.Source)
}
if strings.Contains(sel.DirectCredentialEnv.AppID, secretMarkerValue) {
t.Errorf("Selection.DirectCredentialEnv.AppID leaked secret marker: %q", sel.DirectCredentialEnv.AppID)
}
for _, k := range sel.DirectCredentialEnv.Keys {
if strings.Contains(k, secretMarkerValue) {
t.Errorf("Selection.DirectCredentialEnv.Keys leaked secret marker: %q", k)
}
}
// State #7 always clears p.selection on the secret-invalid path (see
// doResolveAccount); assert it is zero-valued, which trivially implies no
// marker anywhere in it and guards against a future field being populated
// from the failed resolution.
if sel.Source != "" || sel.DirectCredentialEnv.Present ||
sel.DirectCredentialEnv.AppID != "" || len(sel.DirectCredentialEnv.Keys) != 0 {
t.Errorf("Selection() = %+v, want zero value on profile_secret_invalid", sel)
}
}
// State #8: P valid, E complete, app_id matches -> profile source, env present+matched.
func TestSelection_State8_ProfileMatchesEnv(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_a") // matches profile app_id
t.Setenv(envvars.CliAppSecret, envSecretValue)
writeConfigTenantA(t)
cp := newProvider(t, "tenant_a", true)
sel, err := cp.Selection(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sel.Source != credential.SourceFlagProfile {
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceFlagProfile)
}
if !sel.DirectCredentialEnv.Present || !sel.DirectCredentialEnv.Matched {
t.Fatalf("DirectCredentialEnv = %+v, want Present && Matched", sel.DirectCredentialEnv)
}
if sel.DirectCredentialEnv.AppID != "cli_a" {
t.Errorf("DirectCredentialEnv.AppID = %q, want cli_a", sel.DirectCredentialEnv.AppID)
}
assertNoSecretLeak(t, "state8", string(sel.Source), sel.DirectCredentialEnv.AppID)
assertNoSecretLeak(t, "state8-keys", sel.DirectCredentialEnv.Keys...)
}
// State #9: P valid, E complete, app_id mismatches -> profile_app_credential_conflict.
func TestSelection_State9_Conflict(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_x") // mismatches profile app_id cli_a
t.Setenv(envvars.CliAppSecret, envSecretValue)
writeConfigTenantA(t)
cp := newProvider(t, "tenant_a", true)
_, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeProfileAppCredentialConflict {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeProfileAppCredentialConflict)
}
ve := asValidationError(t, err)
if ve.ProfileAppID != "cli_a" {
t.Errorf("profile_app_id = %q, want cli_a", ve.ProfileAppID)
}
if ve.EnvAppID != "cli_x" {
t.Errorf("env_app_id = %q, want cli_x", ve.EnvAppID)
}
assertNoSecretLeak(t, "state9", ve.Message, ve.Hint)
}
// State #10: P valid, E partial -> app_credential_incomplete (env-partial wins).
func TestSelection_State10_ProfileWithEnvPartial(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, envSecretValue) // only secret set
writeConfigTenantA(t)
cp := newProvider(t, "tenant_a", true)
_, err := cp.Selection(context.Background())
if got := subtypeOf(t, err); got != errs.SubtypeAppCredentialIncomplete {
t.Fatalf("subtype = %q, want %q", got, errs.SubtypeAppCredentialIncomplete)
}
ce := asConfigError(t, err)
if !slices.Contains(ce.MissingKeys, envvars.CliAppID) {
t.Errorf("missing_keys = %v, want to contain %q", ce.MissingKeys, envvars.CliAppID)
}
assertNoSecretLeak(t, "state10", ce.Message, ce.Hint)
assertNoSecretLeak(t, "state10-keys", ce.MissingKeys...)
}
// fakeSidecarProvider is a NON-env extension provider (Priority 0, Name !=
// directCredentialProviderName) that always returns a non-nil account. It
// stands in for the sidecar extension provider without needing a build tag.
type fakeSidecarProvider struct {
appID string
}
func (f *fakeSidecarProvider) Name() string { return "sidecar" }
func (f *fakeSidecarProvider) Priority() int { return 0 }
func (f *fakeSidecarProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: f.appID, Brand: extcred.Brand("feishu")}, nil
}
func (f *fakeSidecarProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: "sidecar-tok", Source: "sidecar"}, nil
}
// Regression: a NON-env extension provider (sidecar) that returns an account
// must win outright even when a profile is set. It must NOT be treated as a
// direct-credential env account: no profile arbitration, no
// profile_app_credential_conflict (even though its app_id differs from the
// profile's cli_a), and DirectCredentialEnv.Present must stay false (§4.2 —
// no direct env vars are set). This proves the success-account provider gating
// mirrors the block-path guard.
func TestSelection_NonEnvExtensionProviderWinsOverProfile(t *testing.T) {
t.Setenv(envvars.CliAppID, "") // no direct env credential
t.Setenv(envvars.CliAppSecret, "") // no direct env credential
writeConfigTenantA(t) // profile tenant_a exists, app_id cli_a
sidecar := &fakeSidecarProvider{appID: "sidecar_app"} // differs from cli_a
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "tenant_a")
cp := credential.NewCredentialProvider([]extcred.Provider{sidecar}, defaultAcct, nil, nil)
cp.WithProfile("tenant_a", true)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The sidecar account is used as-is, NOT overridden by profile arbitration.
if acct == nil || acct.AppID != "sidecar_app" {
t.Fatalf("account = %+v, want AppID sidecar_app (sidecar wins outright)", acct)
}
sel, err := cp.Selection(context.Background())
if err != nil {
t.Fatalf("unexpected Selection error: %v", err)
}
// No misreported direct env credential (§4.2).
if sel.DirectCredentialEnv.Present {
t.Errorf("DirectCredentialEnv.Present = true, want false (no direct env vars set)")
}
// The mismatched app_id (sidecar_app vs profile cli_a) must NOT trigger a
// profile_app_credential_conflict: both ResolveAccount and Selection above
// returned nil errors, so no conflict (or any other) error was produced.
// Guard against a future regression that surfaces a conflict via Selection.
if _, selErr := cp.Selection(context.Background()); selErr != nil {
if subtypeOf(t, selErr) == errs.SubtypeProfileAppCredentialConflict {
t.Errorf("got profile_app_credential_conflict, want none for non-env provider")
}
}
assertNoSecretLeak(t, "nonenv-sidecar", string(sel.Source), sel.DirectCredentialEnv.AppID)
}
// State #1: P none, E none, C present -> config default (currentApp).
func TestSelection_State1_ConfigDefault(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
writeConfigTenantA(t) // CurrentApp = tenant_a
cp := newProvider(t, "", false)
sel, err := cp.Selection(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sel.Source != credential.SourceConfigCurrentApp {
t.Fatalf("source = %q, want %q", sel.Source, credential.SourceConfigCurrentApp)
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
// CredentialSourceKind is the wire-stable App/credential selection source.
type CredentialSourceKind string
const (
SourceFlagProfile CredentialSourceKind = "flag:--profile"
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
)
// DirectCredentialEnv describes the state of direct app credential env vars.
// It never carries a secret value — only names and the non-sensitive app_id.
type DirectCredentialEnv struct {
Present bool `json:"present"`
Keys []string `json:"keys,omitempty"`
AppID string `json:"appId,omitempty"`
Matched bool `json:"matched,omitempty"`
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
}
// IdentitySelection is the explainable result of credential selection.
// It carries NO secret value (security: §5.1).
type IdentitySelection struct {
Source CredentialSourceKind
DirectCredentialEnv DirectCredentialEnv
}
// Explicit reports whether the identity was actively specified by the
// user/agent (flag or env), which governs no-fallback behavior.
func (s IdentitySelection) Explicit() bool {
switch s.Source {
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
return true
default:
return false
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import "testing"
func TestIdentitySelectionExplicit(t *testing.T) {
cases := []struct {
src CredentialSourceKind
explicit bool
}{
{SourceFlagProfile, true},
{SourceEnvProfile, true},
{SourceEnvAppID, true},
{SourceConfigCurrentApp, false},
{SourceConfigFirstApp, false},
}
for _, c := range cases {
sel := IdentitySelection{Source: c.src}
if sel.Explicit() != c.explicit {
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
}
}
}

View File

@@ -10,6 +10,7 @@ const (
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliProfile = "LARKSUITE_CLI_PROFILE"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode)

View File

@@ -1,7 +1,7 @@
---
name: lark-shared
version: 1.0.0
description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, or handling _notice JSON."
description: "Use for lark-cli setup/auth/profile-selection tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, handling _notice JSON, profile/tenant/app-identity selection, or any request to make this task/session (or all following lark-cli commands) run under a specific profile/tenant — via LARKSUITE_CLI_PROFILE, --profile, unset LARKSUITE_CLI_PROFILE, or whoami identity diagnostics."
---
# lark-cli 共享规则
@@ -32,7 +32,7 @@ lark-cli config init --new
| 获取全部权限 | `lark-cli auth login --domain all --no-wait --json` |
| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json``--domain` 可重复,也可用逗号分隔 |
| 指定单个 scope 授权 | `lark-cli auth login --scope "<scope>" --no-wait --json` |
| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity``verified``identities.user.status``identities.user.userName``identities.user.openId`(用户 open id`identities.user.tokenStatus``identities.user.scope` |
| 检查当前登录态、是谁登录、token 是否有效 | 必须运行 `lark-cli auth status --json --verify`;回答时引用 `identity``verified``identities.user.status``identities.user.userName``identities.user.openId`(用户 open id`identities.user.tokenStatus``identities.user.scope` |
| 快速查看当前身份状态 | `lark-cli whoami`;实际生效的那一个身份 |
| 退出当前机器的用户登录态 | `lark-cli auth logout --json``loggedOut:true` 表示注销成功 |
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope优先复用错误里的 `console_url` |
@@ -126,6 +126,10 @@ lark-cli auth login --device-code <device_code>
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
- **禁止缓存 `verification_url``device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
## Profile 选择
Profile selection: use `--profile <profile-or-appId>` for one command; for a task/session, prefix later `lark-cli` commands with `LARKSUITE_CLI_PROFILE=<profile-or-appId>` unless shell env persists, where you may `export` once and later `unset`. Ask if the selector is unknown; do not merely promise. Use `whoami` for the effective app/profile identity and `auth status --json --verify` for OAuth token state. Do not run `lark-cli profile use` unless changing the long-term default, and do not set `LARKSUITE_CLI_APP_ID`/`LARKSUITE_CLI_APP_SECRET` unless direct credentials are provided.
## 更新检查
lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_notice.update` 字段(含 `message``command` 等)。