Compare commits

..

1 Commits

Author SHA1 Message Date
calendar-assistant
6a9f554a68 docs: remove write-then-readback constraint from lark-calendar skill
写操作后不再要求等待2秒回查验证,直接信任API返回结果,
减少不必要的耗时。

Change-Id: I2d7e1ecab4450839f8232e65d43bca39e851ae22
2026-06-22 14:21:45 +08:00
261 changed files with 4206 additions and 18020 deletions

View File

@@ -9,11 +9,7 @@ permissions:
contents: read
jobs:
# All platforms (incl. darwin keychain_signer) are CGO-free and cross-compiled
# on a single ubuntu runner in one goreleaser run (one checksums.txt). The
# darwin signer's runtime FFI is validated separately by the signer-test job.
goreleaser:
needs: signer-test-macos
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -38,21 +34,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Validate the macOS keychain signer on real hardware. The release binaries are
# cross-compiled on ubuntu (CGO-free purego FFI), so this is the only step that
# needs a Mac — and it gates the release rather than producing it.
signer-test-macos:
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- name: Keychain signer round-trip (CGO-free purego FFI)
run: LARK_KEYCHAIN_IT=1 CGO_ENABLED=0 go test -tags keychain_signer -run Keychain -v ./internal/keysigner/
publish-npm:
needs: goreleaser
runs-on: ubuntu-22.04

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -49,16 +49,9 @@ linters:
- gocritic
- depguard
- forbidigo
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters:
- forbidigo
- path: internal/vfs/
@@ -84,14 +77,25 @@ linters:
text: shortcuts-no-raw-http
linters:
- forbidigo
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -110,6 +114,22 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -5,53 +5,15 @@ before:
- python3 scripts/fetch_meta.py
builds:
# Linux & Windows: pure-Go TPM 2.0 signer is compiled in by default (no build
# tag), cross-compiled with CGO disabled — the binaries ship the platform key
# signer for private_key_jwt. windows/arm64 is the one exception: the sks
# Windows dependency stack (go-ole) has no arm64 support, so the signer file is
# arch-excluded there and that binary falls back to client_secret only.
- id: linux
binary: lark-cli
main: .
- binary: lark-cli
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- linux
goarch:
- amd64
- arm64
- id: windows
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- windows
goarch:
- amd64
- arm64
# macOS: the keychain signer calls Security.framework via runtime FFI (purego),
# so it is CGO-free, compiled into every darwin build (no build tag), and
# cross-compiles from the same ubuntu runner as linux/windows.
- id: darwin
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
@@ -61,7 +23,7 @@ archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
format: zip
files:
- README.md
- LICENSE

View File

@@ -2,29 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
@@ -1212,7 +1189,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53

View File

@@ -33,11 +33,7 @@ build: fetch_meta
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) .
vet: fetch_meta
# -unsafeptr=false: the macOS keychain signer dereferences dylib data-symbol
# addresses from purego.Dlsym (uintptr->unsafe.Pointer over stable C memory) —
# safe FFI, but go vet's unsafeptr can't prove it and has no inline suppress.
# golangci-lint still runs full govet (honoring the //nolint:govet) in CI.
go vet -unsafeptr=false ./...
go vet ./...
# fmt-check fails when any file would be reformatted by gofmt. Keep this
# in sync with the fast-gate "Check formatting" step in CI.

View File

@@ -10,7 +10,6 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -124,13 +123,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
@@ -160,10 +153,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
@@ -206,13 +196,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -259,7 +243,7 @@ func apiRun(opts *APIOptions) error {
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -279,7 +263,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
return nil
}
@@ -296,11 +280,11 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
return output.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
@@ -329,10 +313,10 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
return output.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
@@ -347,11 +331,11 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
return output.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,

View File

@@ -33,9 +33,12 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.BareError
var bare *output.ExitError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
}
if bare.Detail != nil {
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
}
if stderr.Len() != 0 {

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -60,7 +59,7 @@ func authListRun(opts *ListOptions) error {
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {

View File

@@ -265,7 +265,7 @@ func authLoginRun(opts *LoginOptions) error {
if err != nil {
return err
}
authResp, err := larkauth.RequestDeviceAuthorization(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand, finalScope, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
}
@@ -325,7 +325,7 @@ func authLoginRun(opts *LoginOptions) error {
// Step 3: Poll for token
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
@@ -415,7 +415,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
if !result.OK {

View File

@@ -847,7 +847,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
@@ -878,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// BareError with ExitAuth so the dispatcher only propagates the exit code
// ExitError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
@@ -886,7 +886,7 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}
@@ -945,13 +945,16 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
}
}

View File

@@ -4,7 +4,8 @@
package completion
import (
"github.com/larksuite/cli/errs"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -31,9 +32,7 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}

View File

@@ -212,7 +212,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
return "", langSelectionError(err)
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = string(lang)
opts.UILang = lang

View File

@@ -20,29 +20,35 @@ import (
"github.com/larksuite/cli/internal/output"
)
// wantErrDetail is the normalized comparison shape for a typed error's wire
// fields: Type is the error's Category string ("validation", "config", ...),
// alongside Message and Hint.
type wantErrDetail struct {
Type string
Message string
Hint string
}
// assertExitError checks the full structured error in one assertion against a
// typed error (ValidationError or ConfigError), normalizing its Category /
// Message / Hint to wantDetail.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed errors (ValidationError, ConfigError) — they normalize to the same
// wantDetail fields. The wantDetail.Type is matched against the typed error's
// Category string ("validation", "config", etc.).
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
@@ -53,13 +59,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDe
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -173,21 +179,15 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
})
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default
err := configBindRun(&BindOptions{Factory: f, Source: ""})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -835,19 +835,17 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
t.Fatal("expected error for unbound workspace")
}
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
}
// The workspace name stays out of the wire subtype; it only appears in
// the message.
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
@@ -1189,7 +1187,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
// iterates a map — ordering is non-deterministic. DeepEqual inline against
// each accepted variant so every ErrDetail field (Type, Code, Message,
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
base := wantErrDetail{
base := output.ErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
@@ -1205,7 +1203,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
}
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
@@ -1232,7 +1230,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
@@ -1252,7 +1250,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
})
@@ -1538,7 +1536,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1558,7 +1556,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1584,7 +1582,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
@@ -1612,7 +1610,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
openclawPath := filepath.Join(openclawDir, "openclaw.json")
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1674,7 +1672,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",

View File

@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",

View File

@@ -12,7 +12,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -93,16 +92,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
}
}
@@ -193,7 +192,7 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, "", "", nil); err != nil {
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
@@ -206,88 +205,6 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
}
}
func TestKeyRefFromResult_PrivateKeyJWT(t *testing.T) {
ref := keyRefFromResult(&configInitResult{
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "lark-cli-default",
})
if ref == nil {
t.Fatal("keyRefFromResult returned nil")
}
if ref.Source != "tee" || ref.ID != "lark-cli-default" {
t.Fatalf("key ref = %#v, want tee/lark-cli-default", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodPrivateKeyJWT}); ref != nil {
t.Fatalf("missing key label should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodClientSecret, KeyLabel: "ignored"}); ref != nil {
t.Fatalf("client_secret should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(nil); ref != nil {
t.Fatalf("nil result should not persist key ref, got %#v", ref)
}
}
func TestSaveInitConfig_PrivateKeyJWTSingleAppPersistsSecretlessAuth(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveInitConfig("", nil, f, "cli_pkjwt", core.SecretInput{}, core.BrandFeishu, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveInitConfig private_key_jwt single app: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if len(got.Apps) != 1 {
t.Fatalf("apps len = %d, want 1", len(got.Apps))
}
app := got.Apps[0]
if app.AppId != "cli_pkjwt" {
t.Fatalf("AppId = %q, want cli_pkjwt", app.AppId)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt config must stay secretless, AppSecret=%#v", app.AppSecret)
}
}
func TestSaveInitConfig_PrivateKeyJWTProfilePersistsSecretlessAuth(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveInitConfig("prod", &core.MultiAppConfig{}, f, "cli_pkjwt", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveInitConfig private_key_jwt profile: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := got.FindApp("prod")
if app == nil {
t.Fatalf("profile prod not saved: %#v", got.Apps)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt profile must stay secretless, AppSecret=%#v", app.AppSecret)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
@@ -316,21 +233,15 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
@@ -470,82 +381,12 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
},
}
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "", nil)
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
if err == nil {
t.Fatal("expected conflict error")
}
// A name/appId conflict is user input — a typed validation error naming the
// offending flag, not a system storage failure.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if verr.Param != "--name" {
t.Errorf("param = %q, want --name", verr.Param)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "conflicts with existing appId") {
t.Errorf("message = %q, want conflict description", verr.Message)
}
}
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
// validation error (e.g. the --name conflict) is not reclassified as an
// internal storage failure on its way up through the save call sites.
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
var verr *errs.ValidationError
if !errors.As(wrapSaveConfigError(conflict), &verr) {
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
}
var ierr *errs.InternalError
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
t.Fatalf("untyped failure must become internal/storage")
}
}
func TestSaveAsProfile_UpdatePersistsPrivateKeyJWT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "prod",
AppId: "cli_prod",
AppSecret: core.PlainSecret("old-secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
}},
}
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "prod", "cli_prod", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveAsProfile update private_key_jwt: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := got.FindApp("prod")
if app == nil {
t.Fatalf("profile prod not saved: %#v", got.Apps)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt update must stay secretless, AppSecret=%#v", app.AppSecret)
}
if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_1" {
t.Fatalf("same-app update should preserve users, Users=%#v", app.Users)
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
}
}

View File

@@ -6,11 +6,13 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -19,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
)
@@ -32,7 +33,6 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
AuthMethod string // --auth-method for --new: "" (default client_secret) | private_key_jwt
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
@@ -41,8 +41,6 @@ type ConfigInitOptions struct {
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Restore bool // Restore re-registers the app already in config to recover a lost credential
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
@@ -85,13 +83,11 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
}
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().StringVar(&opts.AuthMethod, "auth-method", "", "auth method for --new: client_secret (default) or private_key_jwt (signed by a platform key, no app secret)")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
@@ -131,14 +127,17 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin
return o.New || o.AppID != "" || o.AppSecretStdin
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
@@ -157,44 +156,11 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
}
}
// removeStaleSecretForPKJWT clears a secret left in the keychain when the SAME
// appId is migrated from client_secret to private_key_jwt. cleanupOldConfig
// explicitly skips a matching appId, and saveAsProfile only cleans up on an
// appId change, so a same-appId migration would orphan the old secret. This
// fills that gap. RemoveSecretStore only deletes Source=="keychain" entries, so
// the new pkjwt tee key handle is never touched.
func removeStaleSecretForPKJWT(existing *core.MultiAppConfig, profileName, appID string, kc keychain.KeychainAccess) {
if existing == nil {
return
}
var prior *core.AppConfig
if profileName != "" {
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
prior = &existing.Apps[idx]
}
} else {
prior = existing.CurrentAppConfig("")
}
if prior != nil && prior.AppId == appID && !prior.AppSecret.IsZero() {
core.RemoveSecretStore(prior.AppSecret, kc)
}
}
// keyRefFromResult builds the TEE key reference to persist for a private_key_jwt
// registration result, or nil for client_secret.
func keyRefFromResult(r *configInitResult) *core.SecretRef {
if r != nil && r.AuthMethod == core.AuthMethodPrivateKeyJWT && r.KeyLabel != "" {
return &core.SecretRef{Source: "tee", ID: r.KeyLabel}
}
return nil
}
// saveAsOnlyApp overwrites config.json with a single-app config.
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
AuthMethod: authMethod, KeyRef: keyRef,
}},
}
return core.SaveMultiAppConfig(config)
@@ -203,11 +169,9 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
// saveInitConfig saves a new/updated app config, respecting --profile mode.
// With profileName: appends or updates the named profile (preserves other profiles).
// Without profileName: cleans up old config and saves as the only app.
// authMethod/keyRef carry the credential type: ("", nil) for client_secret,
// (private_key_jwt, &{tee,label}) for the secretless TEE flow.
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
if profileName != "" {
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, authMethod, keyRef)
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
var prior i18n.Lang
@@ -216,27 +180,13 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)), authMethod, keyRef)
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
multi := existing
if multi == nil {
multi = &core.MultiAppConfig{}
@@ -255,24 +205,18 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
multi.Apps[idx].AuthMethod = authMethod
multi.Apps[idx].KeyRef = keyRef
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
AuthMethod: authMethod,
KeyRef: keyRef,
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})
}
return core.SaveMultiAppConfig(multi)
@@ -305,8 +249,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
// survive; legacy *output.ExitError also passes through; everything else
// (filesystem, keychain, etc.) is wrapped as InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
@@ -314,6 +258,10 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
@@ -350,94 +298,6 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
return core.SaveMultiAppConfig(existing)
}
// persistAndProbeResult saves a registration/restore result into profileName and
// runs the post-registration probe. profileName == "" replaces the single app
// (legacy); a named profile is updated in place. Shared by --new and --restore.
func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
existing, _ := core.LoadMultiAppConfig()
// private_key_jwt apps have no secret: persist auth method + TEE key ref.
// Registration success already validated the key (server bound the public
// key), so the app_secret probe is skipped.
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
return wrapSaveConfigError(err)
}
removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain)
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel)
}
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand)
}
// runRestoreFlow re-registers the app already in config to recover a lost
// credential (deleted keychain key / lost app secret). It reads the existing
// app id + auth method + brand from config (no secret needed — that's the lost
// part) and re-runs the device-flow registration with the app id sent on begin,
// so the server re-registers that app instead of creating a new one. The
// re-issued credential is written back to the same profile.
func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error {
if existing == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found").
WithHint("run: lark-cli config init")
}
app := existing.CurrentAppConfig(opts.ProfileName)
if app == nil || app.AppId == "" {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)).
WithHint("run: lark-cli config init")
}
restoreAppID := app.AppId
// Reuse the stored auth method authoritatively — never prompt. Empty on disk
// means client_secret (omitempty back-compat); pass it explicitly so
// resolveRegisterAuthMethod doesn't fall through to the interactive picker.
authMethod := app.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result")
}
// Safety: if the server did not honor app_id (e.g. not yet supported), it may
// have created a NEW app instead of restoring. Warn so the user is not silently
// switched to a different app id.
if result.AppID != restoreAppID {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID)
}
// Write back to the profile we restored: an explicit --name, else the resolved
// app's own name. Empty name => legacy single-app replace.
saveProfile := opts.ProfileName
if saveProfile == "" {
saveProfile = app.Name
}
return persistAndProbeResult(opts, f, saveProfile, result)
}
// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed.
func profileSuffix(profileName string) string {
if profileName == "" {
return ""
}
return fmt.Sprintf(" (profile %q)", profileName)
}
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
@@ -468,17 +328,6 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
// --restore recovers an existing app; it is incompatible with creating a new
// app (--new) or importing one non-interactively (--app-id / stdin secret).
if opts.Restore {
if opts.New {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore")
}
if opts.AppID != "" || opts.AppSecretStdin {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore")
}
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
@@ -486,8 +335,8 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
@@ -504,7 +353,10 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
return langSelectionError(err)
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = string(lang)
opts.UILang = lang
@@ -512,26 +364,34 @@ func configInitRun(opts *ConfigInitOptions) error {
msg := getInitMsg(opts.UILang)
// Mode: Restore (--restore) — re-register the app already in config.
if opts.Restore {
return runRestoreFlow(opts, existing, f, msg)
}
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), opts.AuthMethod, msg, "")
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
}
return persistAndProbeResult(opts, f, opts.ProfileName, result)
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
// Mode 4: Interactive TUI (terminal)
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
result, err := runInteractiveConfigInit(opts.Ctx, f, opts.AuthMethod, msg)
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
if err != nil {
return err
}
@@ -542,23 +402,14 @@ func configInitRun(opts *ConfigInitOptions) error {
existing, _ := core.LoadMultiAppConfig()
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
// Secretless create: persist auth method + TEE key ref, no secret.
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
return wrapSaveConfigError(err)
}
removeStaleSecretForPKJWT(existing, opts.ProfileName, result.AppID, f.Keychain)
if err := runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel); err != nil {
return err
}
} else if result.AppSecret != "" {
if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
@@ -662,8 +513,8 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
type authMethodTestSigner struct{}
func (authMethodTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) Sign(context.Context, keysigner.KeyRef, []byte) ([]byte, string, error) {
return nil, "", nil
}
// TestResolveRegisterAuthMethod covers the non-interactive gating paths. The
// darwin keychain signer is compiled into every build, so the test cannot rely
// on the binary lacking a signer — it forces a known no-signer state for the
// rejection cases, then registers a stub for the success case.
func TestResolveRegisterAuthMethod(t *testing.T) {
f := &cmdutil.Factory{}
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(nil)
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodClientSecret); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("client_secret: got (%q, %v), want (client_secret, nil)", m, err)
}
if m, err := resolveRegisterAuthMethod(f, ""); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("default: got (%q, %v), want (client_secret, nil)", m, err)
}
if _, err := resolveRegisterAuthMethod(f, "bogus"); err == nil {
t.Error("bogus auth-method: expected error")
}
if _, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err == nil {
t.Error("private_key_jwt without a signer: expected error")
}
keysigner.Register(authMethodTestSigner{})
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err != nil || m != core.AuthMethodPrivateKeyJWT {
t.Errorf("private_key_jwt with signer: got (%q, %v), want (private_key_jwt, nil)", m, err)
}
}
// TestValidatePKJWTKeyBinding covers the guard that rejects a registration
// resolving to private_key_jwt with no signing key bound (e.g. an existing
// secret-based app was selected on the confirm page).
func TestValidatePKJWTKeyBinding(t *testing.T) {
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, ""); err == nil {
t.Error("pkjwt with empty keyLabel: expected error")
}
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, "agent-key"); err != nil {
t.Errorf("pkjwt with keyLabel: expected nil, got %v", err)
}
if err := validatePKJWTKeyBinding(core.AuthMethodClientSecret, ""); err != nil {
t.Errorf("client_secret: expected nil, got %v", err)
}
}
// TestResolveFinalAuthMethod locks the authoritative-method logic. The 2nd case
// is the real bug: we requested private_key_jwt but the server resolved to an
// existing client_secret app — we must persist client_secret, not pkjwt.
func TestResolveFinalAuthMethod(t *testing.T) {
if m := resolveFinalAuthMethod([]string{"client_secret", "private_key_jwt"}, core.AuthMethodClientSecret); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("prefers private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod([]string{"client_secret"}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodClientSecret {
t.Errorf("server client_secret must override requested pkjwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("fallback to requested when server is silent: got %q", m)
}
// Explicit empty slice (not just nil) also falls back to requested — the same
// len()==0 back-compat allowance the init guard relies on to let private_key_jwt
// proceed against an older server (see internal/auth
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods).
if m := resolveFinalAuthMethod([]string{}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("empty []string should fall back to requested private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, ""); m != core.AuthMethodClientSecret {
t.Errorf("default to client_secret: got %q", m)
}
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,15 +26,12 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
@@ -51,15 +48,12 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
}

View File

@@ -5,11 +5,7 @@ package config
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
@@ -17,26 +13,22 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// configInitResult holds the result of the interactive config init flow.
type configInitResult struct {
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle when AuthMethod == private_key_jwt
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
}
// runInteractiveConfigInit shows an interactive TUI for config init.
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Phase 1: Choose mode
var mode string
form1 := huh.NewForm(
@@ -62,7 +54,7 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMetho
return runExistingAppForm(f, msg)
}
return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "")
return runCreateAppFlow(ctx, f, "", msg)
}
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
@@ -154,59 +146,9 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil
}
// resolveRegisterAuthMethod decides the auth method for a new-app registration.
// An explicit --auth-method flag wins; otherwise, on an interactive terminal with
// a TEE signer available, the user is prompted; the default is client_secret.
func resolveRegisterAuthMethod(f *cmdutil.Factory, flag string) (string, error) {
signerAvailable := keysigner.Active() != nil
switch flag {
case core.AuthMethodPrivateKeyJWT:
if !signerAvailable {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
"--auth-method private_key_jwt requires a platform key signer, which is unavailable on this device/build").
WithHint("omit --auth-method (or pass --auth-method client_secret) to register with an app secret")
}
return core.AuthMethodPrivateKeyJWT, nil
case core.AuthMethodClientSecret:
return core.AuthMethodClientSecret, nil
case "":
// fall through to interactive / default
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown --auth-method %q (use client_secret or private_key_jwt)", flag)
}
if signerAvailable && f.IOStreams.IsTerminal {
var choice string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Authentication method").
Options(
huh.NewOption("App Secret (client_secret)", core.AuthMethodClientSecret),
huh.NewOption("Secure key signer, no secret (private_key_jwt)", core.AuthMethodPrivateKeyJWT),
).
Value(&choice),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return "", output.ErrBare(1)
}
return "", err
}
return choice, nil
}
return core.AuthMethodClientSecret, nil
}
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
// authMethodFlag is the raw --auth-method value ("" when unset).
// restoreAppID, when non-empty, is sent on the registration begin request so the
// server re-registers that existing app (credential recovery) instead of creating
// a new one. Empty preserves the normal new-app flow.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, authMethodFlag string, msg *initMsg, restoreAppID string) (*configInitResult, error) {
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
var larkBrand core.LarkBrand
if brandOverride != "" {
larkBrand = brandOverride
@@ -234,51 +176,11 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
larkBrand = parseBrand(brand)
}
authMethod, err := resolveRegisterAuthMethod(f, authMethodFlag)
if err != nil {
return nil, err
}
// Step 1: Request app registration (begin).
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
// For private_key_jwt: init to obtain a nonce, then sign a TEE attestation
// (carrying the public key in its jwk header) to send with begin.
beginOpts := larkauth.AppRegistrationBeginOptions{}
keyLabel := ""
if authMethod == core.AuthMethodPrivateKeyJWT {
signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod
initResp, initErr := larkauth.RequestAppRegistrationInit(httpClient)
if initErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr)
}
// An empty SupportedAuthMethods is intentionally treated as "older server /
// unknown": len()==0 makes this guard false, so the requested
// private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's
// back-compat fallback to the requested method. Only an explicit list that
// omits private_key_jwt rejects here.
if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
"server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")).
WithHint("register with --auth-method client_secret instead")
}
keyLabel = keysigner.DefaultKeyLabel
attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now())
if signErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr)
}
beginOpts = larkauth.AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: attestation,
}
}
// Restore flow: re-register the existing app instead of creating a new one.
beginOpts.RestoreAppID = restoreAppID
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}
@@ -311,28 +213,18 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
}
// The final auth method is decided by the user/admin at confirmation and
// returned by poll — NOT necessarily what we requested. Selecting an existing
// client_secret app, for example, yields client_secret even though we sent
// private_key_jwt. Trust the result so we persist the truth.
finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod)
// Lark brand special case (client_secret only): a lark-tenant app returns its
// secret only from the lark endpoint. private_key_jwt returns no secret, so
// this retry does not apply.
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// Step 4: Handle Lark brand special case
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
}
finalMethod = resolveFinalAuthMethod(result.AuthMethods, authMethod)
}
if result.ClientID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id")
}
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret")
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response
@@ -343,67 +235,13 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
finalBrand = core.BrandFeishu
}
// Surface a downgrade: requested private_key_jwt but the app resolved to a
// secret-based method (e.g. an existing app was selected). The key was NOT
// bound, so we must store the secret method, not private_key_jwt.
if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod)
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
keyToStore := ""
if finalMethod == core.AuthMethodPrivateKeyJWT {
keyToStore = keyLabel
}
if err := validatePKJWTKeyBinding(finalMethod, keyToStore); err != nil {
return nil, err
}
return &configInitResult{
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise
AuthMethod: finalMethod,
KeyLabel: keyToStore,
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret,
}, nil
}
// validatePKJWTKeyBinding rejects a registration that resolved to
// private_key_jwt without a signing key bound to it. keyLabel is non-empty only
// when the local flow chose private_key_jwt and signed a TEE attestation; a
// resolved method of private_key_jwt with no key handle would save an unusable
// config (rejected later at config load, surfacing as "saved OK, fails on first
// use"), so it is caught here at registration time instead.
func validatePKJWTKeyBinding(finalMethod, keyLabel string) error {
if finalMethod == core.AuthMethodPrivateKeyJWT && keyLabel == "" {
return errs.NewConfigError(errs.SubtypeInvalidClient,
"registration resolved to private_key_jwt but no signing key was bound to this app (an existing secret-based app may have been selected)").
WithHint("re-register with: lark-cli config init --new --auth-method private_key_jwt")
}
return nil
}
// resolveFinalAuthMethod picks the authoritative method from the poll result,
// preferring private_key_jwt, then client_secret. It falls back to the requested
// method when the server returns nothing (older servers).
func resolveFinalAuthMethod(serverMethods []string, requested string) string {
if len(serverMethods) == 0 {
if requested == "" {
return core.AuthMethodClientSecret
}
return requested
}
for _, m := range serverMethods {
if m == core.AuthMethodPrivateKeyJWT {
return core.AuthMethodPrivateKeyJWT
}
}
for _, m := range serverMethods {
if m == core.AuthMethodClientSecret {
return core.AuthMethodClientSecret
}
}
return serverMethods[0]
}

View File

@@ -4,14 +4,10 @@
package config
import (
"errors"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
type initMsg struct {
@@ -101,12 +97,3 @@ func promptLangSelection() (i18n.Lang, error) {
}
return lang, nil
}
// langSelectionError maps a promptLangSelection failure to its exit surface:
// user abort exits bare with code 1; any other failure is internal.
func langSelectionError(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keysigner"
)
// probeTimeout is the total wall-clock budget for the credential probe step
@@ -91,32 +90,3 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// runProbePKJWT does a best-effort key-binding validation after a private_key_jwt
// config is saved: it signs a client_assertion with the local platform key and
// mints a token. A typed error (a deterministic server rejection — e.g. the key
// is not bound to this app) is propagated so `config init` exits non-zero with
// the canonical envelope; untyped errors (transport / HTTP / parse / timeout)
// are swallowed (return nil). The mint itself is the probe — no second call.
func runProbePKJWT(parent context.Context, factory *cmdutil.Factory, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) error {
if factory == nil || signer == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
if _, err := credential.FetchTATWithAssertion(ctx, httpClient, brand, clientID, signer, keyLabel); err != nil {
// Typed = deterministic credential rejection → propagate. Untyped
// (transport / HTTP / parse / timeout) is ambiguous → stay silent.
if errs.IsTyped(err) {
return err
}
return nil
}
return nil
}

View File

@@ -6,11 +6,6 @@ package config
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
crand "crypto/rand"
"crypto/sha256"
"errors"
"io"
"net/http"
@@ -22,17 +17,14 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
oauthHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
oauthCalls int
probeReq *http.Request
probeBody string
}
@@ -56,50 +48,10 @@ func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
case strings.HasSuffix(req.URL.Path, "/authen/v2/oauth/token"):
f.oauthCalls++
if f.oauthHandler == nil {
return jsonResp(200, `{"access_token":"t-jwt"}`), nil
}
return f.oauthHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
// probeTestSigner is an in-memory real ECDSA P-256 signer used to sign the
// client_assertion in runProbePKJWT tests (authMethodTestSigner returns a nil
// key and cannot sign).
type probeTestSigner struct{ key *ecdsa.PrivateKey }
func newProbeTestSigner(t *testing.T) *probeTestSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
if err != nil {
t.Fatal(err)
}
return &probeTestSigner{key: k}
}
func (p *probeTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(crand.Reader, p.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
@@ -333,42 +285,3 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
// must stay silent and not block.
assertSilent(t, err, errBuf)
}
// runProbePKJWT: a deterministic server rejection (invalid_client) is propagated
// as a typed ConfigError so config init exits non-zero.
func TestRunProbePKJWT_DeterministicReject_Propagates(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"invalid_client","error_description":"unknown key"}`), nil
}}
f, errBuf := fakeFactory(t, rt)
err := runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key")
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected propagated typed error, got %T %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbePKJWT must not write stderr, got %q", errBuf.String())
}
}
// runProbePKJWT: ambiguous upstream noise (HTTP 503) is swallowed — silent, exit 0.
func TestRunProbePKJWT_Ambiguous_Silent(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(503, `unavailable`), nil
}}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
}
// runProbePKJWT: a successful mint returns nil.
func TestRunProbePKJWT_Success_Silent(t *testing.T) {
rt := &fakeRT{} // default oauth handler returns 200 + access_token
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
}
// runProbePKJWT: a nil signer is a defensive no-op (should not be reached, must
// not panic).
func TestRunProbePKJWT_NilSigner_Silent(t *testing.T) {
f, errBuf := fakeFactory(t, &fakeRT{})
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", nil, "k"), errBuf)
}

View File

@@ -10,25 +10,9 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
// TestRunRestoreFlow_NothingToRestore covers the early guards that return before
// any network/registration call: no config at all, and a config whose resolved
// app has no app id (nothing to send on begin).
func TestRunRestoreFlow_NothingToRestore(t *testing.T) {
// No config on disk.
if err := runRestoreFlow(&ConfigInitOptions{}, nil, nil, nil); err == nil {
t.Fatal("expected error when there is no config to restore")
}
// Config present but the resolved app has no app id.
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: ""}}}
if err := runRestoreFlow(&ConfigInitOptions{}, existing, nil, nil); err == nil {
t.Fatal("expected error when the resolved app has no app id")
}
}
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
@@ -81,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics: a typed ValidationError must keep ExitValidation rather than
// being downgraded to InternalError.
// exit semantics (regression: typed ValidationError was being downgraded to
// InternalError by the legacy *output.ExitError-only passthrough).
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
@@ -106,6 +90,18 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
}
}
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
got := wrapUpdateExistingProfileErr(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
}
if exitErr.Code != 7 {
t.Errorf("Code = %d, want 7", exitErr.Code)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)
@@ -135,62 +131,3 @@ func assertValidationParam(t *testing.T, err error, wantParam string) {
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
}
}
// countingKeychain is an in-memory KeychainAccess that records whether Remove
// was invoked, so the stale-secret cleanup can be asserted without a real OS
// keychain.
type countingKeychain struct {
store map[string]string
removeCalled bool
}
func newCountingKeychain() *countingKeychain {
return &countingKeychain{store: map[string]string{}}
}
func (k *countingKeychain) Get(service, account string) (string, error) {
v, ok := k.store[service+"/"+account]
if !ok {
return "", keychain.ErrNotFound
}
return v, nil
}
func (k *countingKeychain) Set(service, account, value string) error {
k.store[service+"/"+account] = value
return nil
}
func (k *countingKeychain) Remove(service, account string) error {
k.removeCalled = true
delete(k.store, service+"/"+account)
return nil
}
func TestRemoveStaleSecretForPKJWT_SameAppID(t *testing.T) {
kc := newCountingKeychain()
ref, err := core.ForStorage("cli_same", core.PlainSecret("old-secret"), kc) // → Source:"keychain"
if err != nil {
t.Fatal(err)
}
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_same", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_same", kc)
if !kc.removeCalled {
t.Error("same appId with keychain secret: expected kc.Remove to be invoked")
}
}
func TestRemoveStaleSecretForPKJWT_DifferentAppID(t *testing.T) {
kc := newCountingKeychain()
ref, _ := core.ForStorage("cli_old", core.PlainSecret("old-secret"), kc)
kc.removeCalled = false // ForStorage does not call Remove, but reset to be safe
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_old", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_new", kc)
if kc.removeCalled {
t.Error("different appId: must NOT remove")
}
}
func TestRemoveStaleSecretForPKJWT_NilExisting(t *testing.T) {
removeStaleSecretForPKJWT(nil, "", "cli_x", newCountingKeychain()) // must not panic
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"sync"
@@ -15,12 +14,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
@@ -97,7 +94,7 @@ func doctorRun(opts *DoctorOptions) error {
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
@@ -111,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
cfg, err := f.Config()
if err != nil {
hint := ""
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}
@@ -134,9 +131,6 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
}
// ── 3b. private_key_jwt / TEE signer (local; runs even with --offline) ──
checks = append(checks, teeSignerCheck(opts.Ctx, cfg))
// ── 4 & 5. Endpoint reachability ──
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
@@ -150,54 +144,6 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
return warn(name, id.Message, id.Hint)
}
const teeUnavailableHint = "ensure the device secure hardware is accessible (Linux TPM: add your user to the 'tss' group or run with sufficient privileges)"
// teeSignerCheck reports the private_key_jwt signing backend (TEE/TPM) status.
// The probe is local hardware only (no network), so it runs even with --offline;
// in a build without a TEE signer it short-circuits without touching any
// hardware. It is a hard requirement for private_key_jwt apps and purely
// informational for client_secret apps.
func teeSignerCheck(ctx context.Context, cfg *core.CliConfig) checkResult {
usesPKJWT := cfg != nil && cfg.AuthMethod == core.AuthMethodPrivateKeyJWT
info, ok, err := keysigner.ProbeActiveHardware(ctx)
return teeCheckResult(info, ok, err, usesPKJWT)
}
// teeCheckResult maps a hardware probe to a doctor check. Split out from
// teeSignerCheck so the full matrix is unit-testable without a TPM.
func teeCheckResult(info keysigner.HardwareInfo, ok bool, probeErr error, usesPKJWT bool) checkResult {
const name = "tee_signer"
// No signer registered → private_key_jwt is unsupported on this build.
if !ok {
if usesPKJWT {
return fail(name,
"app uses private_key_jwt but this build has no TEE key signer",
"the platform key signer ships by default on macOS, Linux, and Windows/amd64; this platform (e.g. Windows/arm64) has none — use a supported platform or re-register with --auth-method client_secret")
}
return skip(name, "no TEE signer in this build (only private_key_jwt is affected; client_secret is unaffected)")
}
backend := info.Backend
if backend == "" {
backend = "tee"
}
switch {
case probeErr != nil:
return warn(name, fmt.Sprintf("%s signer present but probe errored: %s", backend, probeErr), "")
case info.Available:
if info.VendorName != "" {
return pass(name, fmt.Sprintf("%s TEE available (%s)", backend, info.VendorName))
}
return pass(name, fmt.Sprintf("%s TEE available", backend))
case usesPKJWT:
return fail(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
default:
return warn(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
}
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
@@ -287,90 +233,14 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
}
}
workspace := core.CurrentWorkspace().Display()
// A terminal on STDOUT gets a readable report; pipes, redirects, scripts and
// tests keep the stable JSON contract (NO_COLOR disables ANSI styling).
// StdoutIsTerminal checks stdout specifically — IOStreams.IsTerminal reflects
// stdin, which would wrongly send the human report into `doctor | jq`.
if f.IOStreams.StdoutIsTerminal() {
renderDoctorHuman(f.IOStreams.Out, workspace, checks, allOK, os.Getenv("NO_COLOR") == "")
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": allOK,
"workspace": workspace,
"checks": checks,
})
result := map[string]interface{}{
"ok": allOK,
"workspace": core.CurrentWorkspace().Display(),
"checks": checks,
}
output.PrintJson(f.IOStreams.Out, result)
if !allOK {
return output.ErrBare(1)
}
return nil
}
// renderDoctorHuman writes a readable health report: one aligned line per check
// with a colored status tag, an indented hint when present, and a summary line.
func renderDoctorHuman(w io.Writer, workspace string, checks []checkResult, allOK, color bool) {
const (
green = "\033[32m"
yellow = "\033[33m"
red = "\033[31m"
gray = "\033[90m"
bold = "\033[1m"
reset = "\033[0m"
)
colorOf := map[string]string{"pass": green, "warn": yellow, "fail": red, "skip": gray}
tagOf := map[string]string{"pass": "PASS", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"}
paint := func(code, s string) string {
if !color || code == "" {
return s
}
return code + s + reset
}
nameW := 0
for _, c := range checks {
if len(c.Name) > nameW {
nameW = len(c.Name)
}
}
fmt.Fprintf(w, "\n%s (workspace: %s)\n\n", paint(bold, "lark-cli doctor"), workspace)
var passN, warnN, failN, skipN int
for _, c := range checks {
tag := tagOf[c.Status]
if tag == "" {
tag = "????"
}
fmt.Fprintf(w, " %s %-*s %s\n", paint(colorOf[c.Status], "["+tag+"]"), nameW, c.Name, c.Message)
if c.Hint != "" {
fmt.Fprintf(w, " %-*s %s\n", nameW, "", paint(gray, "↳ "+c.Hint))
}
switch c.Status {
case "pass":
passN++
case "warn":
warnN++
case "fail":
failN++
case "skip":
skipN++
}
}
headline := paint(green, "healthy")
if !allOK {
headline = paint(red, "problems found")
}
fmt.Fprintf(w, "\n %s — %d passed", headline, passN)
if warnN > 0 {
fmt.Fprintf(w, ", %d warning(s)", warnN)
}
if failN > 0 {
fmt.Fprintf(w, ", %d failed", failN)
}
if skipN > 0 {
fmt.Fprintf(w, ", %d skipped", skipN)
}
fmt.Fprintln(w)
}

View File

@@ -4,18 +4,14 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -143,107 +139,6 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
assertCheck(t, got.Checks, "identity_ready", "pass")
}
func TestTeeCheckResult(t *testing.T) {
avail := keysigner.HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
unavail := keysigner.HardwareInfo{Backend: "tpm2", Reason: "open /dev/tpmrm0: permission denied"}
cases := []struct {
name string
info keysigner.HardwareInfo
ok bool
probeErr error
pkjwt bool
want string
}{
{"no signer + private_key_jwt → fail", keysigner.HardwareInfo{}, false, nil, true, "fail"},
{"no signer + client_secret → skip", keysigner.HardwareInfo{}, false, nil, false, "skip"},
{"available + private_key_jwt → pass", avail, true, nil, true, "pass"},
{"available + client_secret → pass", avail, true, nil, false, "pass"},
{"unavailable + private_key_jwt → fail", unavail, true, nil, true, "fail"},
{"unavailable + client_secret → warn", unavail, true, nil, false, "warn"},
{"probe error → warn", keysigner.HardwareInfo{Backend: "tpm2"}, true, errors.New("boom"), true, "warn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := teeCheckResult(tc.info, tc.ok, tc.probeErr, tc.pkjwt)
if got.Name != "tee_signer" {
t.Errorf("name = %q, want tee_signer", got.Name)
}
if got.Status != tc.want {
t.Errorf("status = %q, want %q (msg=%q)", got.Status, tc.want, got.Message)
}
})
}
}
// TestDoctorRun_TeeSignerWired proves the tee_signer check is part of doctorRun.
// It asserts the build-independent invariant (a client_secret app must never
// FAIL on TEE) so the test passes whether or not a signer is compiled in.
func TestDoctorRun_TeeSignerWired(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{
Name: "default", AppId: "test-app",
AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
var c *checkResult
for i := range got.Checks {
if got.Checks[i].Name == "tee_signer" {
c = &got.Checks[i]
}
}
if c == nil {
t.Fatalf("tee_signer check not present in doctor output: %#v", got.Checks)
}
if c.Status == "fail" {
t.Errorf("tee_signer = fail for a client_secret app; want skip/warn/pass (msg=%q)", c.Message)
}
}
func TestRenderDoctorHuman(t *testing.T) {
var buf bytes.Buffer
checks := []checkResult{
pass("cli_version", "1.0.50"),
warn("tee_signer", "tpm2 signer present but TEE unavailable", "add your user to the 'tss' group"),
fail("identity_ready", "no usable identity", "run: lark-cli auth status --verify"),
skip("endpoint_open", "skipped (--offline)"),
}
renderDoctorHuman(&buf, "local", checks, false, false)
out := buf.String()
for _, want := range []string{
"lark-cli doctor", "workspace: local",
"[PASS]", "cli_version", "1.0.50",
"[WARN]", "tee_signer", "↳ add your user to the 'tss' group",
"[FAIL]", "identity_ready", "↳ run: lark-cli auth status --verify",
"[SKIP]", "endpoint_open",
"problems found", "1 passed", "1 warning(s)", "1 failed", "1 skipped",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n---\n%s", want, out)
}
}
if strings.Contains(out, "\033[") {
t.Errorf("color=false but ANSI escapes present:\n%s", out)
}
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
for _, check := range checks {

View File

@@ -15,6 +15,7 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -48,6 +49,32 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.

View File

@@ -386,9 +386,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
errOutputDirUnsafe = errors.New("unsafe --output-dir")
)
func parseParams(raw []string) (map[string]string, error) {

View File

@@ -270,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.BareError
var exit *output.ExitError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.BareError); ok {
if t, ok := target.(**output.BareError); ok {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
*t = e
return true
}

View File

@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present")
}
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if bareErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}

View File

@@ -5,10 +5,10 @@ package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -40,65 +40,31 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
// The offending flag is carried structurally on Params (replaces the
// legacy detail map) and named in the message.
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
}
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
t.Error("Params[0].Reason must explain the rejection")
}
if !strings.Contains(verr.Message, "--rang") {
t.Errorf("message should name the offending flag, got %q", verr.Message)
}
// The ranked candidate rides on the param as a machine-readable suggestion
// so an agent can retry without parsing prose.
if len(verr.Params) == 1 {
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
}
// The same candidate is also carried in the human-facing hint.
if !strings.Contains(verr.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", verr.Hint)
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -9,12 +9,10 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -104,7 +102,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed error envelope; allowed commands are
// command's RunE returns a typed ExitError envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
@@ -129,27 +127,13 @@ max_risk: write
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The denial taxonomy (reason_code, layer, rule) is preserved on the
// wrapped *platform.CommandDeniedError cause and folded into the hint.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain should expose *platform.CommandDeniedError")
}
if cd.ReasonCode != "command_denylisted" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
}
if !strings.Contains(verr.Hint, "command_denylisted") {
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
}
// im/+send must be denied (domain not in Allow).

View File

@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
@@ -34,8 +34,16 @@ import (
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// typed error each time.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
// which is part of the legacy error surface that predates the typed error
// contract introduced by errs/. New code MUST NOT add new callers — the
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
// when the platform-extension framework migrates. This wrapper is retained
// only for the existing in-tree call sites; it will be removed once they
// have moved to the typed surface.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
@@ -72,65 +80,120 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a typed validation error (failed_precondition) before any
// command runs.
// failure as a structured plugin_install envelope before any command
// runs.
// Deprecated: installPluginInstallErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin install failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() error {
makeErr := func() *output.ExitError {
var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
WithCause(installErr)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": internalplatform.ReasonInstallFailed,
},
},
Err: installErr,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
WithCause(installErr)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The hint separates the two failure modes by reason code:
// Restrict). The design separates the envelope type:
//
// - "invalid_rule" - single bad rule
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
//
// Either way the CLI must NOT silently continue with a broken policy.
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
// via its internal makeErr lambda. New code MUST NOT add such producers —
// plugin conflict failures should surface as a typed *errs.XxxError once the
// platform-extension framework migrates. This helper is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
WithCause(err)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /
// on-call) can tell the two failure modes apart.
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin lifecycle failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error {
makeErr := func() *output.ExitError {
reasonCode := "lifecycle_failed"
hookName := ""
detail := map[string]any{
"reason_code": reasonCode,
}
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
hookName = le.HookName
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
}
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if hookName != "" {
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
}
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
}
installFatalGuard(rootCmd, makeErr)
}
@@ -156,7 +219,14 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
func walkGuard(cmd *cobra.Command, makeErr func() error) {
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
// of the legacy error surface that predates the typed error contract
// introduced by errs/. New code MUST NOT add new callers — the platform-
// extension guard plumbing will switch to typed errs.* errors when the
// platform-extension framework migrates. This wrapper is retained only for
// the existing in-tree call sites; it will be removed once they have moved
// to the typed surface.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
if cmd == nil {
return
}

View File

@@ -6,14 +6,12 @@ package cmd
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
@@ -34,7 +32,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
// install a PersistentPreRunE that returns a structured *output.ExitError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
@@ -95,31 +93,26 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the typed validation error the
// install guard produces: a failed_precondition *errs.ValidationError
// (exit 2) whose message + hint preserve the plugin name and the
// install_failed reason code (the recovery info that lived in the legacy
// detail map).
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
}
if !strings.Contains(verr.Hint, "policy") {
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
}
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
}
}

View File

@@ -8,13 +8,11 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -158,23 +156,19 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
}
err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
}
// The namespaced hook name and the abort semantics are preserved in the
// message so a caller can identify which plugin hook rejected the call.
if !strings.Contains(verr.Message, "policy-plugin.policy") {
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "aborted") {
t.Errorf("message should describe the abort, got %q", verr.Message)
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
}
// errors.As must still reach the original AbortError so consumers
@@ -415,20 +409,15 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code multiple_restrict_plugins is folded into the hint so the
// operator can distinguish a multi-Restrict conflict from a bad rule.
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
}
}
@@ -458,20 +447,15 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code invalid_rule is folded into the hint, distinct from the
// multiple_restrict_plugins conflict path.
if !strings.Contains(verr.Hint, "invalid_rule") {
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
}
}
@@ -500,24 +484,19 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
}
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
// hook name are folded into the hint so audit / on-call can tell the
// failure mode and which hook failed.
if !strings.Contains(verr.Hint, "lifecycle_failed") {
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "lc.start") {
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
}
}
@@ -541,20 +520,12 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// A panicking startup hook is distinguished from a returned error by
// reason_code lifecycle_panic in the hint.
if !strings.Contains(verr.Hint, "lifecycle_panic") {
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
}
}
@@ -608,24 +579,19 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
// The recovered panic surfaces as a structured error naming the
// namespaced hook (p.boom) and describing the panic, so the process
// never crashes and the caller can attribute the failure.
if !strings.Contains(verr.Message, "p.boom") {
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
}
}
@@ -687,24 +653,19 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
// A panic in the wrapper FACTORY (not just the inner handler) is
// recovered into the same structured panic error, naming the
// namespaced hook fac.bad-factory.
if !strings.Contains(verr.Message, "fac.bad-factory") {
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
}
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -54,9 +53,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
WithCause(err).
WithParam("--name")
return output.ErrValidation("%v", err)
}
langPref, err := cmdutil.ParseLangFlag(lang)
@@ -67,57 +64,46 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
// Read secret from stdin
if !appSecretStdin {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
WithHint("use --app-secret-stdin and pipe the secret").
WithParam("--app-secret-stdin")
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
}
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
WithCause(err).
WithParam("--app-secret-stdin")
return output.ErrValidation("failed to read secret from stdin: %v", err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
WithHint("pipe the app secret to stdin").
WithParam("--app-secret-stdin")
return output.ErrValidation("stdin is empty, expected app secret")
}
appSecret := strings.TrimSpace(scanner.Text())
if appSecret == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
WithHint("pipe a non-empty app secret to stdin").
WithParam("--app-secret-stdin")
return output.ErrValidation("app secret read from stdin is empty")
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
}
multi = &core.MultiAppConfig{}
}
// Check name uniqueness
if multi.FindApp(name) != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
WithHint("choose a different name, or remove the existing profile first").
WithParam("--name")
return output.ErrValidation("profile %q already exists", name)
}
// Check app-id uniqueness — keychain stores secrets by appId, so
// multiple profiles sharing the same appId would collide on credentials.
for _, a := range multi.Apps {
if a.AppId == appID {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()).
WithParam("--app-id")
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
}
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
parsedBrand := core.ParseBrand(brand)
@@ -148,7 +134,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -46,7 +45,7 @@ func profileListRun(f *cmdutil.Factory) error {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
if multi == nil || len(multi.Apps) == 0 {
output.PrintJson(f.IOStreams.Out, []profileListItem{})

View File

@@ -11,7 +11,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -51,16 +50,6 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
@@ -106,9 +95,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
}
})
}
@@ -417,226 +406,17 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper()
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeStorage {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
if exitErr.Code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if internalErr.Cause == nil {
t.Fatalf("cause = nil, want wrapped underlying error")
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
}
if !strings.Contains(internalErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// assertValidationError asserts err is a typed *errs.ValidationError with the
// given subtype, message fragment, and exit code 2.
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if !strings.Contains(valErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
return valErr
}
func saveTwoProfiles(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestProfileAddRun_ValidationErrors(t *testing.T) {
t.Run("invalid profile name", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("empty stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
})
t.Run("blank secret on stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader(" \n")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
})
t.Run("duplicate profile name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
})
t.Run("duplicate app-id", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
if valErr.Param != "--app-id" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
}
})
}
func TestProfileUseRun_ValidationErrors(t *testing.T) {
t.Run("no previous profile for toggle", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "-")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
}
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
t.Run("invalid new name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "bad name!")
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("old profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "ghost", "fresh")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("new name already exists", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "target")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("cannot remove the only profile", func(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "solo",
Apps: []core.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "solo")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileListRun(f)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped load error")
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
}
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -41,12 +40,11 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
idx := multi.FindAppIndex(name)
if idx < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
if len(multi.Apps) == 1 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
return output.ErrValidation("cannot remove the only profile")
}
app := &multi.Apps[idx]
@@ -67,7 +65,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
// Best-effort credential cleanup after config commit

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -31,7 +30,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
return output.ErrValidation("%v", err)
}
multi, err := core.LoadOrNotConfigured()
@@ -41,7 +40,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
idx := multi.FindAppIndex(oldName)
if idx < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
}
// Check new name uniqueness across other profiles, allowing renames to this
@@ -51,8 +50,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
continue
}
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
WithHint("choose a different name")
return output.ErrValidation("profile %q already exists", newName)
}
}
@@ -68,7 +66,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -41,15 +40,14 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
// Handle "-" for toggle-back
if name == "-" {
if multi.PreviousApp == "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to").
WithHint("switch to a profile by name first: lark-cli profile use <name>")
return output.ErrValidation("no previous profile to switch back to")
}
name = multi.PreviousApp
}
app := multi.FindApp(name)
if app == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
targetName := app.ProfileName()
@@ -68,7 +66,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))

View File

@@ -9,10 +9,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
// pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE.
//
// strict-mode keeps its short Message + independent Hint and wraps
// the CommandDeniedError as the Cause by hand; BuildDenialError
// would override Message with the CommandDeniedError.Error() long
// form.
// strict-mode keeps its short Message + independent Hint and
// composes the shared detail.* / wrapped-CommandDeniedError shape
// by hand; BuildDenialError would override Message with the
// CommandDeniedError.Error() long form.
stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity())
@@ -105,9 +105,20 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
// Legacy *output.ExitError producer: this literal predates the
// typed error contract introduced by errs/. New denial sites MUST
// NOT construct *output.ExitError directly — they should return a
// typed *errs.XxxError once the cmdpolicy framework migrates.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: stubMessage,
Hint: stubHint,
Detail: cmdpolicy.DenialDetailMap(cd),
},
Err: cd,
}
},
}
}

View File

@@ -8,7 +8,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -248,12 +247,9 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
}
}
// Pins the strict-mode typed envelope: a failed_precondition
// *errs.ValidationError (exit 2) carrying the short historical Message,
// a Hint that still surfaces the policy layer + reason code (the
// safety-critical recovery info that lived in the legacy detail map),
// and the wrapped *platform.CommandDeniedError so external agents can
// still inspect the structured denial taxonomy via errors.As.
// Pins the strict-mode envelope shape: structured detail.* / wrapped
// CommandDeniedError for external agents, AND the historical short
// Message + independent Hint for existing consumers.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
@@ -266,33 +262,30 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
t.Fatalf("strict-mode stub RunE should return error")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("err is not *errs.ValidationError: %T", err)
var ee *output.ExitError
if !errors.As(err, &ee) {
t.Fatalf("err is not *output.ExitError: %T", err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
if ee.Detail == nil {
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
if ee.Detail.Type != "command_denied" {
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
}
// Short historical Message is preserved verbatim.
if verr.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Message = %q, want short historical form", verr.Message)
dm, ok := ee.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
}
// The denial layer + reason code remain user-readable in the hint, and
// the historical switch-policy guidance is still appended.
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
}
if !strings.Contains(verr.Hint, "identity_not_supported") {
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
}
if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
}
// The structured denial taxonomy survives on the wrapped cause.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
@@ -303,12 +296,15 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
}
if cd.PolicySource != "strict-mode" {
t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource)
}
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
}
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
}
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
}
}
// strictModeStubFrom must write the denial annotations so the hook

View File

@@ -13,12 +13,17 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -212,37 +217,56 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// are promoted via errcompat to their typed errs/ counterparts, with the
// original preserved in the Cause chain.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// typed envelope writer, which lifts extension fields (missing_scopes,
// console_url, challenge_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// Promote legacy error shapes into typed errs/ before envelope marshal.
// NeedAuthorizationError check is first because it is the more specific
// shape; *core.ConfigError check follows. errors.As preserves the original
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
//
// Outer-typed short-circuit: if err is already a typed *errs.* error,
// skip PromoteXxxError so the producer's Subtype / Hint / extension
// fields are not overwritten by a coarser promoted shape derived from a
// legacy error buried in its Cause chain. Promotion is only for legacy
// untyped entry points.
if !isOuterTypedError(err) {
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
err = errcompat.PromoteAuthError(needAuthErr)
} else {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
err = errcompat.PromoteConfigError(cfgErr)
}
}
}
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
applyNeedAuthorizationHint(f, err)
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the signal / plain-text paths.
// Problem; in that case we fall through to the legacy bridge below.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
@@ -255,63 +279,58 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return pfErr.Code
}
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
return false
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
// to gate PromoteXxxError so a producer's outer typed envelope is never
// overwritten by a coarser shape derived from its legacy Cause.
func isOuterTypedError(err error) bool {
_, ok := err.(errs.TypedError)
return ok
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
@@ -342,10 +361,13 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
// each fail structured (exit 2) instead of degrading to help + exit 0.
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
@@ -361,13 +383,28 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range unknown {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
}
return verr
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
@@ -379,13 +416,19 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(misplaced) == 0 {
return cmd.Help()
}
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range misplaced {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
}
return verr
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
@@ -399,12 +442,27 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
// Record the offending subcommand and its ranked candidates as a param with
// machine-readable Suggestions so an agent can retry without parsing the
// hint; the hint carries the same candidates as prose.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
WithHint("%s", hint)
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
},
}
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
@@ -530,34 +588,47 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
@@ -627,3 +698,56 @@ func installTipsHelpFunc(root *cobra.Command) {
}
})
}
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
// Message + Hint match the per-subtype canonical text produced by the typed
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
// This guarantees a caller observing the wire envelope cannot tell whether
// the error reached the dispatcher via the legacy *ExitError bridge or via
// the typed *errs.PermissionError fast path.
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
return
}
// Only the legacy permission-class envelope types route here. "app_status"
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
return
}
larkCode := exitErr.Detail.Code
meta, ok := errclass.LookupCodeMeta(larkCode)
if !ok || meta.Category != errs.CategoryAuthorization {
return
}
// Extract required scopes from API error detail (shared helper). May be
// empty for app-status codes — canonical message + hint still apply.
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
cfg, err := f.Config()
if err != nil {
return
}
// Reuse the same console URL builder as the typed path so both wire
// envelopes carry identical console_url values for the same input.
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
exitErr.Detail.ConsoleURL = consoleURL
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
@@ -26,12 +27,12 @@ import (
"github.com/spf13/cobra"
)
// Canonical strict-mode envelope messages shared across fixtures. The
// switch-policy hint text is asserted by substring in
// assertStrictModeDenialEnvelope.
// Canonical strict-mode envelope strings shared across fixtures
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
@@ -62,46 +63,37 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
return 0
}
// typedErrorEnvelope mirrors the typed wire shape produced by
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
// directly, so "type" is the category, "subtype" is top-level, and there
// is no nested "detail" object. Recovery info (policy source, reason
// code, suggestions) is folded into "hint".
type typedErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Message string `json:"message"`
Hint string `json:"hint"`
Param string `json:"param,omitempty"`
} `json:"error"`
}
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
// legacy nested "detail" object is present (the migration removed it).
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw map[string]any
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
}
if errObj, ok := raw["error"].(map[string]any); ok {
if _, hasDetail := errObj["detail"]; hasDetail {
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
}
}
var env typedErrorEnvelope
var env output.ErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
@@ -213,71 +205,23 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning). The typed envelope is a failed_precondition validation
// error (exit 2); the strict-mode layer + reason code are folded into
// the hint.
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
// a validation/failed_precondition envelope whose message is the short
// historical strict-mode line and whose hint still names the strict_mode
// layer + identity_not_supported reason code (the safety-critical recovery
// info), plus the historical switch-policy guidance.
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "failed_precondition" {
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "strict_mode") {
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
}
}
// assertCheckStrictModeEnvelope pins the typed envelope produced by
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
// --as on shortcuts / service methods / api): a *errs.ValidationError with
// subtype invalid_argument, the canonical strict-mode message, and the
// switch-policy hint.
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "invalid_argument" {
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
}
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "auth/login",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
@@ -288,14 +232,22 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/+messages-search",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
}
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
@@ -325,14 +277,15 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
@@ -343,14 +296,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -361,14 +315,22 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeUserMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/images/create",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeUserMessage,
},
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
@@ -379,14 +341,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
// --- shortcut command ---
@@ -409,43 +372,16 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path.
if code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw struct {
OK bool `json:"ok"`
Identity string `json:"identity"`
Error struct {
Type string `json:"type"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
}
if raw.OK {
t.Errorf("envelope ok = true, want false")
}
if raw.Identity != "bot" {
t.Errorf("identity = %q, want bot", raw.Identity)
}
if raw.Error.Type != "api" {
t.Errorf("error.type = %q, want api", raw.Error.Type)
}
if raw.Error.Code != 230002 {
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
}
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.")
}
// shortcut: typed error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api",
Code: 230002,
Message: "Bot/User can NOT be out of the chat.",
},
})
}
// TestSetupNotices_ColdStart_NoNotice verifies that missing state

View File

@@ -137,6 +137,9 @@ func TestIsCompletionCommand(t *testing.T) {
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
// *errs.SecurityPolicyError flows through the canonical typed envelope
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
@@ -266,11 +269,12 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
// backward-compat alias failing on a cobra-level required flag (which
// short-circuits before RunE) routes through the structured envelope, so the
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
// of being dropped on a plain "Error:" line.
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
@@ -282,9 +286,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -293,96 +297,12 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
// The envelope is typed validation, so the exit code must derive from that
// category (2) — the wire type and the exit code must not disagree.
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
// baseline for auth/config errors: it pins the typed envelope and exit code the
// dispatcher produces for the two source-of-truth shapes, which are constructed
// typed at their origin in internal/auth and internal/core.
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "authentication" {
t.Errorf("error.type = %v, want %q", got, "authentication")
}
if got := errObj["subtype"]; got != "token_missing" {
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
t.Errorf("error.message = %q, must carry the user open id", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
t.Errorf("error.hint = %q, must point at auth login", got)
}
if got := errObj["user_open_id"]; got != "u_golden" {
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
}
})
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "config" {
t.Errorf("error.type = %v, want %q", got, "config")
}
if got := errObj["subtype"]; got != "not_configured" {
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
t.Errorf("error.message = %q, want the not-configured message", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
t.Errorf("error.hint = %q, must point at config init", got)
}
})
}
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
// top-level "error" object, failing the test if the shape is unexpected.
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
t.Helper()
var env map[string]any
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", raw)
}
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -391,45 +311,9 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "validation" {
t.Errorf("error.type = %v, want %q", got, "validation")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
t.Errorf("error.message = %q, must carry the failing flag name", got)
}
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),
// not blamed on the user's input as a validation error.
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
}
if exit != int(output.ExitInternal) {
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
}
}
@@ -453,32 +337,12 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
}
}
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
// contract: a *output.BareError is honored for its exit code while stderr stays
// empty (stdout already carries the result, so the dispatcher must not layer a
// second envelope on top).
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
if errOut.Len() != 0 {
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
}
}
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
// Cause chain renders the producer's TokenExpired subtype + custom hint
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
// shape.
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
// would replace the producer's TokenExpired subtype + custom hint with the
// promoted shape's TokenMissing.
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -630,3 +494,136 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}
}
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
// *output.ExitError dispatch path produces the same canonical Message + Hint
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
// the envelope.
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
larkCode int
legacyErrType string
wantMsgSubstrs []string
wantHintSubstrs []string
wantConsoleURL bool
wantNoAuthLogin bool // hint must not suggest `auth login`
}{
{
name: "99991672 app_scope_not_applied",
larkCode: 99991672,
legacyErrType: "permission",
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
wantConsoleURL: true,
wantNoAuthLogin: true,
},
{
name: "99991679 missing_scope",
larkCode: 99991679,
legacyErrType: "permission",
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
wantHintSubstrs: []string{"lark-cli auth login"},
},
{
name: "99991673 app_unavailable",
larkCode: 99991673,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
wantHintSubstrs: []string{"tenant admin", "install status"},
},
{
name: "99991662 app_disabled",
larkCode: 99991662,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
wantHintSubstrs: []string{"tenant admin", "re-enable"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
// Detail.Type populated by ClassifyLarkError, Detail.Detail
// carrying the permission_violations block so ExtractRequiredScopes
// can recover the missing scope.
scopeForDetail := "drive:drive:read"
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: tc.legacyErrType,
Code: tc.larkCode,
Message: "upstream raw message — must be replaced",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": scopeForDetail},
},
},
},
}
enrichPermissionError(f, exitErr)
for _, sub := range tc.wantMsgSubstrs {
if !strings.Contains(exitErr.Detail.Message, sub) {
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
}
}
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
}
for _, sub := range tc.wantHintSubstrs {
if !strings.Contains(exitErr.Detail.Hint, sub) {
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
}
}
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
}
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
t.Error("ConsoleURL should be populated when missing scopes are present")
}
})
}
}
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
// Detail.Type is neither "permission" nor "app_status" is left untouched —
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: ty,
Code: 99991400,
Message: "untouched",
Hint: "original hint",
},
}
enrichPermissionError(f, exitErr)
if exitErr.Detail.Message != "untouched" {
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
}
if exitErr.Detail.ConsoleURL != "" {
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
}
}
}

View File

@@ -5,11 +5,9 @@ package schema
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
@@ -211,45 +209,6 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
}
}
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for unknown method")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(err.Error(), "Unknown method") {
t.Errorf("expected 'Unknown method' error, got: %v", err)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -127,20 +126,29 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// Typed surface: a validation error (exit 2) whose Params carries the
// offending flag so an agent can recover the token without parsing prose.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
}
}
@@ -164,21 +172,25 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(verr.Message, "missing subcommand") {
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
t.Errorf("params = %v, want one entry named --query", verr.Params)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message = %q, want it to name the group path", verr.Message)
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
}
}
@@ -229,23 +241,45 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
t.Fatal("expected error for unknown subcommand")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
}
if !strings.Contains(verr.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", verr.Message)
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
}
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message should name the group path, got %q", verr.Message)
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help (suggestions, when present, are folded into hint).
if !strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint)
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
}
}
@@ -254,12 +288,13 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
}
if !strings.Contains(verr.Message, "lark-cli drive files") {
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
}
}
@@ -302,10 +337,10 @@ func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
}
}
// unknownSubcommandRunE ranks suggestions across both current and deprecated
// subcommands so a mistyped legacy alias resolves; the closest match is folded
// into the hint.
func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
@@ -314,26 +349,31 @@ func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
// "+reat" is closest to the deprecated +read: the candidate must surface
// both as a machine-readable param suggestion (for agent retry) and in the
// hint, proving ranking spans the deprecated bucket.
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
foundSuggestion := false
for _, s := range verr.Params[0].Suggestions {
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
if s == "+read" {
foundSuggestion = true
found = true
}
}
if !foundSuggestion {
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "+read") {
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
}
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -133,14 +132,12 @@ func updateRun(opts *UpdateOptions) error {
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err))
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest))
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
}
// 3. Compare versions
@@ -169,18 +166,15 @@ func updateRun(opts *UpdateOptions) error {
// --- Output helpers ---
// reportError emits the failure on the requested surface: JSON mode prints the
// {ok:false, error:{type, message}} envelope to stdout and signals the typed
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
})
return output.ErrBare(output.ExitCodeOf(typedErr))
return output.ErrBare(exitCode)
}
return typedErr
return output.Errorf(exitCode, errType, "%s", msg)
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
@@ -234,8 +228,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err))
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
}
if !opts.JSON {

View File

@@ -14,7 +14,6 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -335,88 +334,13 @@ func TestUpdateFetchError_Human(t *testing.T) {
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if netErr.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport)
if exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
}
if got := output.ExitCodeOf(err); got != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, got)
}
}
// TestUpdateInvalidVersion_Human verifies a malformed registry version surfaces
// as a typed internal error in human mode, keeping the legacy exit code 5.
func TestUpdateInvalidVersion_Human(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "not-a-version", nil }
defer func() { fetchLatest = origFetch }()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("expected ExitInternal (%d), got %d", output.ExitInternal, got)
}
}
// TestReportError pins reportError's two surfaces after the typed migration:
// human mode returns the typed error unchanged; JSON mode prints the legacy
// {ok:false, error:{type, message}} envelope and exits bare with the typed
// error's exit code (parity with the legacy explicit exit-code argument).
func TestReportError(t *testing.T) {
t.Run("human mode returns the typed error", func(t *testing.T) {
f, _, _ := newTestFactory(t)
typed := errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: disk full")
err := reportError(&UpdateOptions{JSON: false}, f.IOStreams, "update_error", typed)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
}
if apiErr != typed {
t.Errorf("reportError must return the typed error unchanged")
}
if got := output.ExitCodeOf(err); got != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI)
}
})
t.Run("json mode prints envelope and exits bare with typed code", func(t *testing.T) {
f, stdout, _ := newTestFactory(t)
typed := errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: timeout")
err := reportError(&UpdateOptions{JSON: true}, f.IOStreams, "network", typed)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected bare *output.BareError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitNetwork {
t.Errorf("bare exit code = %d, want %d", bareErr.Code, output.ExitNetwork)
}
out := stdout.String()
if !strings.Contains(out, `"type": "network"`) && !strings.Contains(out, `"type":"network"`) {
t.Errorf("JSON envelope missing type, got: %s", out)
}
if !strings.Contains(out, "failed to check latest version: timeout") {
t.Errorf("JSON envelope missing message, got: %s", out)
}
})
}
func TestUpdateInvalidVersion_JSON(t *testing.T) {
@@ -579,12 +503,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if err == nil {
t.Fatal("expected verification failure")
}
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code)
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
}
out := stdout.String()

View File

@@ -6,16 +6,25 @@ envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three.
Something off in production? See **Troubleshooting**.
This document describes the **typed authoring target**. The refactor lands
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
legacy shapes today — see **Migration** for what is live in each stage.
Migrating an `*output.ExitError` call site? See **Migration**. Something off
in production? See **Troubleshooting**.
## Invariants
1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review.
2. Every typed error has a **Subtype** — a stable
2. Every **newly constructed** typed error has a **Subtype** — a stable
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. Every error path constructs a typed
`*errs.*` error at its origin, so the constraint applies uniformly.
Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
dispatcher's `asExitError` → legacy envelope path (not the typed
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
stage-1 passthrough; its stage-2+ typed migration will subject the
promoted typed error to this Subtype constraint at that time.
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
@@ -26,10 +35,11 @@ Something off in production? See **Troubleshooting**.
unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the
exception: it constructs an `*output.BareError`, a deliberate
silent-exit signal (stdout already carries the answer) that bypasses
the envelope (see **Predicate commands** below).
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
producers still carry a hand-set `Code` until they finish migrating.
`output.ErrBare(code)` is the lone exception: a deliberate
predicate-command signal that bypasses the envelope (see
**Predicate commands** below).
## Wire format
@@ -63,14 +73,13 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
retired.
## Categories
@@ -110,21 +119,20 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
cmd/root.go handleRootError dispatches:
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6;
│ *errs.ConfigError, constructed typed at origin)
├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code
*output.BareError → no envelope (stdout already written); exit = code
└─ Cobra usage error → typed validation envelope (invalid_argument); exit 2
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
├─ *core.ConfigError → promoted to typed via errcompat ↑
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
```
The dispatcher emits a JSON envelope on stderr for both the typed branch and
residual Cobra usage errors (missing required flag, unknown command,
argument validation): the latter are classified into a typed validation
envelope (`invalid_argument`) and exit `2`, matching the explicit flag and
subcommand guards.
Only the typed and `*output.ExitError` branches emit a JSON envelope on
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
subcommand messages) print plain text and exit `1` — consumers must
tolerate that fallback.
### Predicate commands (`output.BareError`)
### Predicate commands (`output.ErrBare`)
A small class of commands is **predicates**: they answer a yes/no
question and signal the answer through the shell exit code so callers
@@ -134,27 +142,19 @@ example — its `README` contract is `exit 0 = ok, 1 = missing`.
These commands deliberately:
1. write a structured JSON answer to **stdout** themselves, and
2. return `output.ErrBare(exitCode)` — an `*output.BareError` to
communicate the exit code to the dispatcher without producing a
`stderr` envelope.
2. return `output.ErrBare(exitCode)` to communicate the exit code to
the dispatcher without producing a `stderr` envelope.
`*output.BareError` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message, only an exit code. It is a
one-bit output-control signal that lives outside the contract for the
same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit
codes without printing anything to stderr: pollution of stderr by a
`output.ErrBare` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message. It is a one-bit output-
control signal that lives outside the contract for the same reason
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
without printing anything to stderr: pollution of stderr by a
predicate's negative answer would break `2>/dev/null` log hygiene in
caller scripts.
A second class also uses `ErrBare`: a command that emits its own complete
structured result envelope on **stdout** under `--json` (e.g. `update`, whose
`{ok:false, error:{type, message}}` is its established output shape) and needs
only the exit code conveyed, with no `stderr` envelope. Like a predicate, its
answer is already on stdout; `ErrBare` carries the exit code alone.
New code should not reach for `ErrBare` unless the command's full answer is
already on stdout — a predicate's yes/no, or a self-contained result envelope
as above. Anything whose error content must reach the caller on `stderr`
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
@@ -214,7 +214,7 @@ exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
out=$(lark-cli ... 2>&1)
code=$?
# Defensive guard: tolerate any non-JSON output before parsing with jq.
# Untyped / Cobra errors print plain text — guard before jq.
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2
exit "$code"
@@ -303,10 +303,9 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site.
(The only exits not derived from `Category` are the
`*output.BareError` and the `*output.PartialFailureError` signals, which
carry their own code by design and sit outside the typed-envelope contract —
see **Predicate commands**.)
(Legacy `*output.ExitError` retains hand-set codes until removal;
`SecurityPolicyError` retains a hand-set code on main until the framework
migration PR retires the carve-out — see **Migration**.)
#### Split `Message`, `Hint`, and `Cause`
@@ -341,54 +340,15 @@ Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
// conflates what + what-to-do + cause into one string
```
#### Validation parameters: `Param` and `Params`
#### `ValidationError.Param` uses the `--flag` form
`ValidationError` carries two additive parameter fields. Both are
optional; a producer sets whichever fits the failure.
When a `*ValidationError` originates from a flag value, `Param` holds the
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
agents grep this field literally to surface "the bad flag was `--X`".
**`Param string` (wire `param`)** — the single offending parameter. When a
`*ValidationError` originates from a flag value, `Param` holds the flag
name with leading dashes (`"--priority"`, not `"priority"`). AI agents
grep this field literally to surface "the bad flag was `--X`". For
positional arguments, use the canonical name without dashes
For positional arguments, use the canonical name without dashes
(`"target_user_id"`).
**`Params []InvalidParam` (wire `params`)** — per-parameter validation
detail, for failures that need to report *which* parameters failed and
*why*, one entry each. Each `errs.InvalidParam` is
`{Name, Reason string, Suggestions []string}`: `Name` identifies the
parameter, `Reason` states why it failed, and the optional `Suggestions`
(wire `suggestions`, omitted when empty) carries ranked candidate
corrections an agent can retry with — the did-you-mean candidates for an
unknown flag or subcommand — without parsing the human-facing `hint`. This
is the CLI's rendering of the RFC 7807 `invalid-params` extension member
(RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the
enclosing envelope already carries `type:"validation"`, so the `invalid_`
qualifier would be redundant on the wire.
`Param` and `Params` are independent additive fields, not alternates of a
single representation. Use `Param` for the common single-parameter error;
use `Params` when one failure spans several parameters or needs a
per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`.
A `params` wire example (multiple parameters each carrying a reason):
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "validation",
"subtype": "invalid_argument",
"message": "2 parameters failed validation",
"params": [
{ "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" },
{ "name": "--end", "reason": "must be after --start" }
]
}
}
```
### Constructing typed errors
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
@@ -418,11 +378,44 @@ them on the dynamic dispatch path where a `Problem` value is composed
once and wrapped per Category branch. Outside that pattern, new code
should reach for the builder.
When the validation logic outgrows a single range check — multiple flags,
format parsing, conditional rules — extract it into a helper that also returns
the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a
helper bound to one shortcut is normal in this codebase; see `parseTimeRange`
in `shortcuts/calendar/calendar_agenda.go`).
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
remain callable during migration but are `// Deprecated:` — new code goes
through the builder.
#### Shortcut `Execute` walkthrough
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
form is `output.ErrValidation("--duration-minutes must be between 1 and
1440")`. The typed migration target (builder form):
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithHint("pass a value in [1, 1440]").
WithParam("--duration-minutes")
}
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary; propagate
}
return nil
}
```
Two patterns visible: a producer site (the typed `*errs.ValidationError`
above) and a propagation site (the `return err` after `runtime.DoAPI`,
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
When the validation logic outgrows a single range check — multiple
flags, format parsing, conditional rules — extract it into a helper that
also returns the typed `*errs.ValidationError`. The helper, not
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
this codebase; see `parseTimeRange` in
`shortcuts/calendar/calendar_agenda.go:144`).
### Wrapping upstream errors
@@ -486,7 +479,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
@@ -495,33 +488,19 @@ top level.
## CI guards
Two golangci-lint rules and the custom `errscontract` AST module enforce the
contract; CI runs all three on every PR.
| Check | Enforces | Where |
|-------|----------|-------|
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
**golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here,
so this spec cannot drift from the lint config):
| Rule | Enforces |
|------|----------|
| forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason |
| errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on |
**errscontract** (`lint/errscontract/`, a separate Go module so its
`golang.org/x/tools` dependency stays out of the shipped binary; run locally
with `go run -C lint . ..`):
| Check | Enforces |
|-------|----------|
| `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) |
| `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code |
`errscontract` also carries framework-internal invariants (nil-safe `Unwrap`,
builder immutability, unwrap symmetry); see `lint/errscontract/` for the full
set and `lint/README.md` for adding a new lint domain.
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
dependency stays out of the shipped `lark-cli` binary's module graph;
see `lint/README.md` for how to add a new lint domain.
## Stability
@@ -531,13 +510,67 @@ set and `lint/README.md` for adding a new lint domain.
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
The deprecated `*output.ExitError` surface is outside these tiers — it
will be removed once business migration completes.
## Migration
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
### Current state
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
### Next: framework migration PR (planned)
A single PR consolidates the work the original §9 spec split across PRs 24 — restricted to framework code, no business sweep:
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
### Business-domain migration (self-service, no central timeline)
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
### Legacy removal
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
### Before / after at a call site
```go
// before (legacy)
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
return errclass.BuildAPIError(parsedResp, cc)
```
```go
// before (legacy validation)
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
// after (builder)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithParam("--duration-minutes")
```
## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category
and Subtype, plus a dispatch test in `internal/errclass/classify_test.go`.
and Subtype, plus a dispatch test in `classify_test.go`.
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch
@@ -580,6 +613,8 @@ string cannot be classified retroactively.
- *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards**
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
Deprecated note on the symbol being replaced.
- *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "errors"
// rawPassthrough marks an error as raw passthrough: the dispatcher must not
// rewrite its message or hint with local enrichment. Raw is
// dispatcher-internal routing state, not a wire field. It is deliberately not
// a typed taxonomy error (no embedded Problem) — it only wraps one.
type rawPassthrough struct{ err error }
func (e *rawPassthrough) Error() string { return e.err.Error() }
func (e *rawPassthrough) Unwrap() error { return e.err }
// MarkRaw wraps err as raw passthrough. MarkRaw(nil) returns nil.
func MarkRaw(err error) error {
if err == nil {
return nil
}
return &rawPassthrough{err: err}
}
// IsRaw reports whether err or any error in its chain is marked raw.
func IsRaw(err error) bool {
var raw *rawPassthrough
return errors.As(err, &raw)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestMarkRawNilReturnsNil(t *testing.T) {
if got := errs.MarkRaw(nil); got != nil {
t.Fatalf("MarkRaw(nil) = %v, want nil", got)
}
}
func TestIsRaw(t *testing.T) {
base := fmt.Errorf("boom")
if !errs.IsRaw(errs.MarkRaw(base)) {
t.Errorf("IsRaw(MarkRaw(err)) = false, want true")
}
if errs.IsRaw(base) {
t.Errorf("IsRaw(bare err) = true, want false")
}
if errs.IsRaw(nil) {
t.Errorf("IsRaw(nil) = true, want false")
}
// Raw marking survives further wrapping above it in the chain.
wrapped := fmt.Errorf("outer: %w", errs.MarkRaw(base))
if !errs.IsRaw(wrapped) {
t.Errorf("IsRaw(wrap(MarkRaw(err))) = false, want true")
}
}
func TestMarkRawPreservesErrorMessage(t *testing.T) {
base := fmt.Errorf("boom")
if got := errs.MarkRaw(base).Error(); got != "boom" {
t.Fatalf("MarkRaw(err).Error() = %q, want %q", got, "boom")
}
}
func TestMarkRawPreservesErrorsIsChain(t *testing.T) {
sentinel := errors.New("sentinel")
wrapped := fmt.Errorf("ctx: %w", sentinel)
if !errors.Is(errs.MarkRaw(wrapped), sentinel) {
t.Fatalf("errors.Is(MarkRaw(err), sentinel) = false, want true")
}
}
func TestProblemOfPunchesThroughMarkRaw(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
raw := errs.MarkRaw(typed)
p, ok := errs.ProblemOf(raw)
if !ok {
t.Fatalf("ProblemOf(MarkRaw(typed)) ok = false, want true")
}
if p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf(MarkRaw(typed)).Category = %v, want %v", p.Category, errs.CategoryValidation)
}
// errors.As still finds the concrete typed error through the raw wrapper.
var ve *errs.ValidationError
if !errors.As(raw, &ve) {
t.Errorf("errors.As(MarkRaw(typed), *ValidationError) = false, want true")
}
}
// TestMarkRawUnwrapsToInnerTypedError pins the envelope-serialization
// contract: UnwrapTypedError must return the inner concrete typed error,
// not the rawPassthrough wrapper. The wrapper has no exported fields, so if it
// were returned the JSON envelope would marshal to an empty "{}" error.
func TestMarkRawUnwrapsToInnerTypedError(t *testing.T) {
base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
typed, ok := errs.UnwrapTypedError(errs.MarkRaw(base))
if !ok {
t.Fatal("UnwrapTypedError(MarkRaw(typed)) must find a typed error")
}
out, err := json.Marshal(typed)
if err != nil {
t.Fatal(err)
}
if string(out) == "{}" {
t.Fatalf("UnwrapTypedError returned the opaque rawPassthrough wrapper; envelope would be empty: %s", out)
}
if got := errs.CategoryOf(typed); got != errs.CategoryValidation {
t.Fatalf("unwrapped category = %q, want validation", got)
}
}

View File

@@ -77,10 +77,6 @@ type ValidationError struct {
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
// Suggestions holds machine-readable, ranked candidate corrections for this
// parameter (e.g. did-you-mean flags or subcommands), so an agent can retry
// without parsing the human-facing hint. Omitted when there are none.
Suggestions []string `json:"suggestions,omitempty"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse

View File

@@ -101,9 +101,9 @@ func TestSecurityPolicyErrorUnwrap(t *testing.T) {
// interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain.
//
// The doc comments on these types claim "nil-receiver safe"; this test
// pins that claim so the behavioral comment cannot silently drift from the
// implementation.
// The doc comments on these types claim "nil-receiver safe" but until this
// test landed nothing actually pinned that claim — exactly the
// behavioral-comment-without-test footgun caught in PR #984 review.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper()
checks := []struct {

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,10 +55,8 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content := msg.Content
if msg.MessageType != "interactive" {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -7,8 +7,8 @@ import "fmt"
// AbortError is returned by a Wrapper that wants to short-circuit the
// command chain (instead of calling next). The framework converts it
// to a typed errs.* error so the JSON envelope carries the structured
// fields agents expect.
// to an *output.ExitError with type "hook" so the JSON envelope carries
// the structured fields agents expect.
//
// HookName is the framework-namespaced name ("secaudit.approval"); the
// Registrar adds the plugin-name prefix automatically.

View File

@@ -7,9 +7,9 @@ import "fmt"
// CommandDeniedError is the structured error returned by a denyStub. Every
// pruned-command execution path -- direct invocation, alias expansion,
// internal call -- returns this exact type. The dispatcher converts it to a
// typed errs.* error; the Layer field carries the denial layer for the
// envelope.
// internal call -- returns this exact type. It is wire-compatible with the
// output.ExitError envelope via the Layer (== error.type) field and the
// detail map produced by ExitError().
//
// Layer values:
//

18
go.mod
View File

@@ -7,8 +7,6 @@ require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
@@ -29,10 +27,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/ebitengine/purego v0.10.1
require (
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
@@ -47,23 +42,12 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/certificate-transparency-go v1.1.2 // indirect
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
github.com/google/go-attestation v0.5.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/go-tspi v0.3.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -73,12 +57,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.31.0 // indirect
)

1213
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -31,11 +31,6 @@ type AppRegistrationResult struct {
ClientID string
ClientSecret string
UserInfo *AppRegUserInfo
// AuthMethods is the authoritative auth method(s) the app must use, as
// decided by the user/admin at confirmation (20260409 `auth_method` field).
// It may differ from what the client requested — e.g. selecting an existing
// client_secret app. Empty on older servers.
AuthMethods []string
}
// AppRegUserInfo contains user info returned from app registration.
@@ -44,81 +39,8 @@ type AppRegUserInfo struct {
TenantBrand string // "feishu" or "lark"
}
// AppRegistrationInit is the response from the app registration init endpoint.
type AppRegistrationInit struct {
Nonce string
SupportedAuthMethods []string // e.g. ["client_secret", "private_key_jwt"]
}
// AppRegistrationBeginOptions parametrizes the registration begin request.
// A zero value selects the legacy client_secret flow, preserving prior behavior.
type AppRegistrationBeginOptions struct {
AuthMethod string // "" => client_secret; core.AuthMethodPrivateKeyJWT
AuthAttestation string // private_key_jwt: the TEE-signed attestation JWT
RestoreAppID string // when set, asks the server to re-register this existing app
}
// RequestAppRegistrationInit performs the init step of the registration flow,
// returning a server nonce (to be embedded in a TEE-signed attestation JWT) and
// the auth methods the server supports for this archetype.
func RequestAppRegistrationInit(httpClient *http.Client) (*AppRegistrationInit, error) {
// Registration always begins against the feishu accounts host (mirrors begin).
endpoint := core.ResolveEndpoints(core.BrandFeishu).Accounts + PathAppRegistration
form := url.Values{}
form.Set("action", "init")
form.Set("archetype", "PersonalAgent")
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration init failed: read body: %w", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("app registration init failed: HTTP %d response not JSON", resp.StatusCode)
}
if _, hasError := data["error"]; resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("app registration init failed: %s", msg)
}
out := &AppRegistrationInit{Nonce: getStr(data, "nonce")}
if methods, ok := data["supported_auth_methods"].([]interface{}); ok {
for _, m := range methods {
if s, ok := m.(string); ok {
out.SupportedAuthMethods = append(out.SupportedAuthMethods, s)
}
}
}
if out.Nonce == "" {
return nil, fmt.Errorf("app registration init failed: server returned no nonce")
}
return out, nil
}
// RequestAppRegistration initiates the app registration device flow (begin step).
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts AppRegistrationBeginOptions, errOut io.Writer) (*AppRegistrationResponse, error) {
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -127,24 +49,11 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + PathAppRegistration
authMethod := opts.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
form := url.Values{}
form.Set("action", "begin")
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", authMethod)
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
if opts.AuthAttestation != "" {
form.Set("auth_attestation", opts.AuthAttestation)
}
// Restore flow: carry the existing app id so the server re-registers it
// rather than creating a new app.
if opts.RestoreAppID != "" {
form.Set("app_id", opts.RestoreAppID)
}
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
@@ -186,24 +95,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
// Prefer the server-provided complete URL (currently /page/launcher); fall
// back to building it from verification_uri, then to /page/launcher. The old
// hard-coded /page/cli is stale — the server now returns /page/launcher.
verificationUriComplete := getStr(data, "verification_uri_complete")
if verificationUriComplete == "" {
base := verificationUri
if base == "" {
base = ep.Open + "/page/launcher"
}
// The server may return verification_uri with its own query (e.g.
// client_id when registering against an existing app), so join with
// the same ?/& logic as BuildVerificationURL.
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
verificationUriComplete = base + sep + "user_code=" + url.QueryEscape(userCode)
}
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
return &AppRegistrationResponse{
DeviceCode: getStr(data, "device_code"),
@@ -215,26 +107,6 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
}, nil
}
// parseAuthMethods normalizes the poll response `auth_method` field, which the
// server returns as a JSON array of strings (e.g. ["private_key_jwt"]) — or, on
// some variants, a single space-separated string.
func parseAuthMethods(v interface{}) []string {
switch t := v.(type) {
case []interface{}:
out := make([]string, 0, len(t))
for _, m := range t {
if s, ok := m.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case string:
return strings.Fields(t)
default:
return nil
}
}
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
func BuildVerificationURL(baseURL, cliVersion string) string {
sep := "&"
@@ -315,7 +187,6 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
AuthMethods: parseAuthMethods(data["auth_method"]),
}
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
result.UserInfo = &AppRegUserInfo{

View File

@@ -4,14 +4,8 @@
package auth
import (
"io"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/smartystreets/goconvey/convey"
)
@@ -37,184 +31,3 @@ func Test_BuildVerificationURL(t *testing.T) {
})
})
}
// captureClient returns an http.Client that records the last request's form body
// and replies with the given JSON payload.
func captureClient(gotBody *url.Values, respJSON string) *http.Client {
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
v, _ := url.ParseQuery(string(b))
*gotBody = v
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
}),
}
}
func TestRequestAppRegistrationInit_ParsesNonceAndMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-123","supported_auth_methods":["client_secret","private_key_jwt"]}`)
out, err := RequestAppRegistrationInit(hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-123" {
t.Errorf("nonce = %q, want n-123", out.Nonce)
}
if len(out.SupportedAuthMethods) != 2 || out.SupportedAuthMethods[1] != "private_key_jwt" {
t.Errorf("methods = %v", out.SupportedAuthMethods)
}
if body.Get("action") != "init" {
t.Errorf("action = %q, want init", body.Get("action"))
}
}
func TestRequestAppRegistrationInit_ErrorOnMissingNonce(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"supported_auth_methods":["client_secret"]}`)
if _, err := RequestAppRegistrationInit(hc); err == nil {
t.Fatal("expected error when server returns no nonce")
}
}
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods covers the older-server
// back-compat path: an empty supported_auth_methods array parses to an empty
// slice, so the init guard in cmd/config/init_interactive.go
// (`len(SupportedAuthMethods) > 0 && !slices.Contains(...)`) stays false and does
// NOT reject the requested private_key_jwt. This aligns with
// resolveFinalAuthMethod(nil/[], private_key_jwt) == private_key_jwt
// (see cmd/config TestResolveFinalAuthMethod).
func TestRequestAppRegistrationInit_EmptySupportedAuthMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-1","supported_auth_methods":[]}`)
out, err := RequestAppRegistrationInit(hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-1" {
t.Errorf("nonce = %q, want n-1", out.Nonce)
}
if len(out.SupportedAuthMethods) != 0 {
t.Errorf("SupportedAuthMethods = %v, want empty", out.SupportedAuthMethods)
}
// Reproduce the init guard expression on the real parsed result: an empty
// slice must NOT reject private_key_jwt.
rejected := len(out.SupportedAuthMethods) > 0 &&
!slices.Contains(out.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT)
if rejected {
t.Error("empty SupportedAuthMethods must allow private_key_jwt (older-server back-compat)")
}
}
const beginRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestAppRegistration_BeginDefaultsToClientSecret(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
if _, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q", body.Get("action"))
}
if body.Get("auth_method") != "client_secret" {
t.Errorf("auth_method = %q, want client_secret (default)", body.Get("auth_method"))
}
if body.Has("auth_attestation") {
t.Errorf("auth_attestation should be absent for client_secret, got %q", body.Get("auth_attestation"))
}
// Normal (non-restore) begin must NOT carry app_id.
if body.Has("app_id") {
t.Errorf("app_id should be absent when RestoreAppID is empty, got %q", body.Get("app_id"))
}
}
// TestRequestAppRegistration_BeginRestoreAppID verifies the restore flow sends the
// existing app id on begin so the server re-registers that app.
func TestRequestAppRegistration_BeginRestoreAppID(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{RestoreAppID: "cli_restore_me"}
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q, want begin", body.Get("action"))
}
if body.Get("app_id") != "cli_restore_me" {
t.Errorf("app_id = %q, want cli_restore_me", body.Get("app_id"))
}
}
func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) {
cases := []struct {
name string
resp string
want string
}{
{
name: "bare verification_uri",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`,
want: "https://example/verify?user_code=uc",
},
{
name: "verification_uri with existing query",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?client_id=cli_x","expires_in":300,"interval":5}`,
want: "https://example/verify?client_id=cli_x&user_code=uc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body url.Values
hc := captureClient(&body, tc.resp)
got, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil)
if err != nil {
t.Fatal(err)
}
if got.VerificationUriComplete != tc.want {
t.Errorf("VerificationUriComplete = %q, want %q", got.VerificationUriComplete, tc.want)
}
})
}
}
func TestParseAuthMethods(t *testing.T) {
if got := parseAuthMethods([]interface{}{"private_key_jwt", "client_secret"}); len(got) != 2 || got[0] != "private_key_jwt" {
t.Errorf("array form = %v", got)
}
if got := parseAuthMethods("client_secret private_key_jwt"); len(got) != 2 || got[1] != "private_key_jwt" {
t.Errorf("string form = %v", got)
}
if got := parseAuthMethods(nil); got != nil {
t.Errorf("nil form = %v, want nil", got)
}
}
func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: "header.claims.sig",
}
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("auth_method") != "private_key_jwt" {
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
}
if body.Get("auth_attestation") != "header.claims.sig" {
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
}
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"fmt"
"net/url"
"time"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// ClientAuth describes how to authenticate the OAuth client at the token
// endpoint: with a client_secret (default) or a TEE-signed client_assertion
// (private_key_jwt).
type ClientAuth struct {
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
Signer keysigner.Signer
KeyLabel string
}
// ClientAuthFromConfig builds a ClientAuth from resolved config, picking up the
// active key signer for private_key_jwt apps.
func ClientAuthFromConfig(cfg *core.CliConfig) ClientAuth {
if cfg == nil {
return ClientAuth{}
}
return ClientAuth{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
Signer: keysigner.Active(),
}
}
func (c ClientAuth) isPrivateKeyJWT() bool { return c.AuthMethod == core.AuthMethodPrivateKeyJWT }
// applyClientAssertion adds client_assertion(+type) to a token-endpoint form for
// private_key_jwt and returns true. For client_secret it returns false, leaving
// the caller to apply its own secret-based authentication. audience is the token
// endpoint URL (the assertion's aud claim).
func (c ClientAuth) applyClientAssertion(ctx context.Context, form url.Values, audience string) (bool, error) {
if !c.isPrivateKeyJWT() {
return false, nil
}
if c.Signer == nil {
return false, fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
}
assertion, err := jwt.SignClientAssertion(ctx, c.Signer, keysigner.KeyRef{Label: c.KeyLabel}, c.AppID, audience, time.Now())
if err != nil {
return false, err
}
form.Set("client_assertion_type", jwt.ClientAssertionType)
form.Set("client_assertion", assertion)
return true, nil
}

View File

@@ -1,109 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"net/url"
"testing"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeAuthSigner is a real in-memory ECDSA P-256 signer for client-auth tests.
type fakeAuthSigner struct{ key *ecdsa.PrivateKey }
func newFakeAuthSigner(t *testing.T) *fakeAuthSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeAuthSigner{key: k}
}
func (f *fakeAuthSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestClientAuth_applyClientAssertion_ClientSecret(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // AuthMethod "" => client_secret
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://aud/token")
if err != nil {
t.Fatal(err)
}
if used {
t.Error("client_secret must not produce a client_assertion")
}
if form.Has("client_assertion") || form.Has("client_assertion_type") {
t.Errorf("form should be untouched, got %v", form)
}
}
func TestClientAuth_applyClientAssertion_PrivateKeyJWT(t *testing.T) {
ca := ClientAuth{
AppID: "cli_a",
AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t),
KeyLabel: "k",
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token")
if err != nil {
t.Fatal(err)
}
if !used {
t.Fatal("expected client_assertion to be applied")
}
if form.Get("client_assertion_type") != jwt.ClientAssertionType {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be present for private_key_jwt")
}
}
func TestClientAuth_applyClientAssertion_NilSigner(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT} // Signer nil
if _, err := ca.applyClientAssertion(context.Background(), url.Values{}, "aud"); err == nil {
t.Fatal("expected error when private_key_jwt has no signer")
}
}
func TestClientAuthFromConfig(t *testing.T) {
ca := ClientAuthFromConfig(&core.CliConfig{
AppID: "cli_x",
AppSecret: "s",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "label-1",
})
if ca.AppID != "cli_x" || ca.AppSecret != "s" || ca.AuthMethod != core.AuthMethodPrivateKeyJWT || ca.KeyLabel != "label-1" {
t.Errorf("ClientAuth = %+v", ca)
}
}

View File

@@ -62,7 +62,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
}
// RequestDeviceAuthorization requests a device authorization code.
func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -77,26 +77,18 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
}
}
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
form := url.Values{}
form.Set("client_id", ca.AppID)
form.Set("client_id", appId)
form.Set("scope", scope)
// private_key_jwt authenticates the client with a signed assertion in the
// body; client_secret uses HTTP Basic.
usedAssertion, err := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if !usedAssertion {
basicAuth := base64.StdEncoding.EncodeToString([]byte(ca.AppID + ":" + ca.AppSecret))
req.Header.Set("Authorization", "Basic "+basicAuth)
}
req.Header.Set("Authorization", "Basic "+basicAuth)
resp, err := httpClient.Do(req)
if err != nil {
@@ -147,7 +139,7 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
}
// PollDeviceToken polls the token endpoint until authorization completes or times out.
func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
if errOut == nil {
errOut = io.Discard
}
@@ -179,16 +171,10 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("device_code", deviceCode)
form.Set("client_id", ca.AppID)
usedAssertion, caErr := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if caErr != nil {
return &DeviceFlowResult{OK: false, Error: "invalid_client", Message: caErr.Error()}
}
if !usedAssertion {
form.Set("client_secret", ca.AppSecret)
}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.Token, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
continue
}

View File

@@ -7,10 +7,8 @@ import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -85,7 +83,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
})
t.Cleanup(restore)
_, err := RequestDeviceAuthorization(context.Background(), httpmock.NewClient(reg), ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "", nil)
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
if err != nil {
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
}
@@ -108,66 +106,6 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
}
}
// captureRT records the last request + body and returns a canned device-auth response.
func captureDeviceAuthClient(gotReq **http.Request, gotBody *string, respJSON string) *http.Client {
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
*gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
*gotBody = string(b)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
})}
}
const deviceAuthRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestDeviceAuthorization_PrivateKeyJWT_UsesAssertionNotBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k"}
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "im:message:send", nil); err != nil {
t.Fatal(err)
}
if req.Header.Get("Authorization") != "" {
t.Errorf("private_key_jwt must NOT send Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Get("client_assertion") == "" {
t.Error("missing client_assertion")
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Has("client_secret") {
t.Error("client_secret must not be present for private_key_jwt")
}
}
func TestRequestDeviceAuthorization_ClientSecret_UsesBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // client_secret
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "", nil); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") {
t.Errorf("client_secret should use Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Has("client_assertion") {
t.Error("client_secret must not send a client_assertion")
}
}
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
got := keychain.FormatAuthCmdline([]string{
@@ -267,7 +205,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "device-code", 0, 10, nil)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}

View File

@@ -6,6 +6,7 @@ package auth
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
@@ -21,10 +22,7 @@ var TokenRetryCodes = map[int]bool{
output.LarkErrTokenExpired: true,
}
// NeedAuthorizationError is the sentinel preserved in the Cause chain of the
// typed missing-UAT error so existing errors.As(&NeedAuthorizationError{})
// consumers keep matching after the construction site moved to the typed
// taxonomy. It is never surfaced on the wire on its own.
// NeedAuthorizationError is thrown when no valid UAT exists.
type NeedAuthorizationError struct {
UserOpenId string
}
@@ -34,31 +32,24 @@ func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
}
// NewNeedUserAuthorizationError builds the typed *errs.AuthenticationError
// returned when no valid UAT exists for userOpenID. The Message keeps the
// need_user_authorization marker, the Hint converges on the same auth-login
// recovery vocabulary as the token-missing surface in internal/client, and the
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
WithUserOpenID(userOpenID).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure. It matches the legacy *NeedAuthorizationError sentinel, which is
// preserved in the Cause chain of the typed missing-UAT error, so errors.As
// traverses into the typed *errs.AuthenticationError as well.
// failure, either as the original auth error or as a wrapped ExitError.
func IsNeedUserAuthorizationError(err error) bool {
if err == nil {
return false
}
var needAuthErr *NeedAuthorizationError
return errors.As(err, &needAuthErr)
if errors.As(err, &needAuthErr) {
return true
}
// Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
}
return strings.Contains(err.Error(), needUserAuthorizationMarker)
}
// SecurityPolicyError is preserved as a Go type alias so existing

View File

@@ -6,7 +6,7 @@ package auth
import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestIsNeedUserAuthorizationError(t *testing.T) {
@@ -22,16 +22,15 @@ func TestIsNeedUserAuthorizationError(t *testing.T) {
}
})
t.Run("typed missing-UAT error carries sentinel in cause", func(t *testing.T) {
// The typed constructor preserves the legacy sentinel in the Cause
// chain, so errors.As traverses into it.
if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) {
t.Fatal("expected typed missing-UAT error to match via its cause chain")
t.Run("wrapped exit error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{})
if !IsNeedUserAuthorizationError(err) {
t.Fatal("expected wrapped ExitError to match")
}
})
t.Run("other error", func(t *testing.T) {
err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout")
err := output.ErrNetwork("API call failed: timeout")
if IsNeedUserAuthorizationError(err) {
t.Fatal("expected unrelated error not to match")
}

View File

@@ -1,153 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package jwt builds compact JWS tokens signed by a keysigner.Signer.
//
// It deliberately depends only on the standard library plus the existing
// google/uuid dependency — no third-party JWT library is introduced, keeping
// go.mod free of new dependencies. The actual signing (and, for ECDSA, the
// ASN.1->r||s conversion) is delegated to the Signer implementation.
package jwt
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/keysigner"
)
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// buildSignedJWT builds a compact JWS:
//
// base64url(header).base64url(claims).base64url(signature)
//
// alg is written into the header (it is part of the signed input) and verified
// against the alg the signer reports, guarding against a header/key mismatch.
// typ defaults to "JWT": the server's client_assertion generalizedValidation
// REQUIRES `typ == "JWT"` (rejects otherwise with "malformed client assertion
// jwt"), even though the spec examples (§8.1/§8.2) show only alg.
func buildSignedJWT(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, alg string, header, claims map[string]any) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
if header == nil {
header = map[string]any{}
}
header["alg"] = alg
if _, ok := header["typ"]; !ok {
header["typ"] = "JWT"
}
hb, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("jwt: marshal header: %w", err)
}
cb, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("jwt: marshal claims: %w", err)
}
signingInput := b64(hb) + "." + b64(cb)
sig, gotAlg, err := signer.Sign(ctx, ref, []byte(signingInput))
if err != nil {
return "", fmt.Errorf("jwt: sign: %w", err)
}
if gotAlg != alg {
return "", fmt.Errorf("jwt: signer alg %q does not match header alg %q", gotAlg, alg)
}
return signingInput + "." + b64(sig), nil
}
// newJTI returns a random unique token identifier.
func newJTI() string { return uuid.NewString() }
// attestationTTL bounds the attestation JWT's lifetime. The init nonce (60s,
// single-use) is the real anti-replay constraint; this is a modest margin for
// clock skew on top of the immediate init→sign→begin round-trip.
const attestationTTL = 2 * time.Minute
// attestationClaims builds the registration attestation claim set per the App
// Registration JWT spec: jti, iat, exp (all required) and the init-issued nonce.
func attestationClaims(nonce string, now time.Time) map[string]any {
return map[string]any{
"jti": newJTI(),
"iat": now.Unix(),
"exp": now.Add(attestationTTL).Unix(),
"nonce": nonce,
}
}
// clientAssertionClaims builds an RFC 7523 client_assertion claim set used to
// mint tokens in place of client_secret. aud is the brand's token endpoint URL.
func clientAssertionClaims(clientID, aud string, now time.Time, ttl time.Duration) map[string]any {
return map[string]any{
"iss": clientID,
"sub": clientID,
"aud": aud,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
"jti": newJTI(),
}
}
// ClientAssertionType is the RFC 7523 client_assertion_type value used for JWT
// bearer client authentication at the token endpoint.
const ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
// defaultAssertionTTL bounds a client_assertion's lifetime.
const defaultAssertionTTL = 5 * time.Minute
// SignAttestation signs the registration attestation JWT. The public key is
// embedded in the JWS "jwk" header so the registration backend can bind it to
// the app during action=begin; the claims carry the server nonce as a
// proof-of-possession challenge.
func SignAttestation(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, nonce string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.EnsureKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: ensure key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
jwk, err := keysigner.PublicKeyJWK(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{"jwk": jwk}, attestationClaims(nonce, now))
}
// SignClientAssertion mints a short-lived RFC 7523 client_assertion: it reads the
// registered key (it must already exist — bound at registration; a missing key is
// an error, not a reason to create a new unbound one), derives the JWS alg from
// the public key, and signs an assertion whose audience is the brand's Open API
// host. The server, holding the public key bound at registration, verifies it in
// place of client_secret. The assertion header carries only alg (no jwk/kid);
// the server locates the key via iss/sub = client_id.
//
// This is the model-independent glue: the assertion JWT is identical whether the
// server augments an existing grant (device_code/refresh_token) with client
// authentication or uses a dedicated jwt-bearer grant — only where the caller
// attaches it differs.
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, clientID, audience string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.PublicKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: public key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{}, clientAssertionClaims(clientID, audience, now, defaultAssertionTTL))
}

View File

@@ -1,254 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package jwt
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeSigner is a real in-memory ECDSA P-256 signer, so tests exercise the full
// JWS path and the produced token is actually cryptographically verifiable.
type fakeSigner struct{ key *ecdsa.PrivateKey }
func newFakeSigner(t *testing.T) *fakeSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeSigner{key: k}
}
func (f *fakeSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
// JOSE ES256: fixed-width big-endian r||s (32 bytes each for P-256).
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestBuildSignedJWT_VerifiableES256(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{Label: "x"}, keysigner.AlgES256,
map[string]any{}, clientAssertionClaims("cli_app", "https://accounts.example/token", now, 5*time.Minute))
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 JWS parts, got %d", len(parts))
}
hb, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("header not base64url: %v", err)
}
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
if hdr["alg"] != "ES256" || hdr["typ"] != "JWT" {
t.Errorf("header = %v, want alg=ES256 typ=JWT (server generalizedValidation requires typ)", hdr)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" || claims["aud"] != "https://accounts.example/token" {
t.Errorf("claims = %v", claims)
}
// Cryptographically verify the signature against the signing input.
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatalf("sig not base64url: %v", err)
}
if len(sig) != 64 {
t.Fatalf("ES256 sig len = %d, want 64", len(sig))
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("signature did not verify")
}
}
func TestBuildSignedJWT_NilSigner(t *testing.T) {
if _, err := buildSignedJWT(context.Background(), nil, keysigner.KeyRef{}, "ES256", nil, nil); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestBuildSignedJWT_AlgMismatch(t *testing.T) {
f := newFakeSigner(t) // always reports ES256
if _, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{}, keysigner.AlgRS256, nil, nil); err == nil {
t.Fatal("expected error when header alg != signer alg")
}
}
func TestBuildSignedJWT_MarshalErrors(t *testing.T) {
f := newFakeSigner(t)
ctx := context.Background()
_, err := buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
map[string]any{"bad": func() {}}, nil)
if err == nil || !strings.Contains(err.Error(), "jwt: marshal header") {
t.Fatalf("header marshal error = %v, want prefix %q", err, "jwt: marshal header")
}
_, err = buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
nil, map[string]any{"bad": make(chan int)})
if err == nil || !strings.Contains(err.Error(), "jwt: marshal claims") {
t.Fatalf("claims marshal error = %v, want prefix %q", err, "jwt: marshal claims")
}
}
func TestSignClientAssertion(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
const aud = "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token"
tok, err := SignClientAssertion(context.Background(), f, keysigner.KeyRef{Label: "k"}, "cli_app", aud, now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["aud"] != aud {
t.Errorf("claims = %v", claims)
}
// Signature must verify against the key's public half.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("client_assertion signature did not verify")
}
}
func TestSignClientAssertion_NilSigner(t *testing.T) {
if _, err := SignClientAssertion(context.Background(), nil, keysigner.KeyRef{}, "cli_app", "aud", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestSignAttestation(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := SignAttestation(context.Background(), f, keysigner.KeyRef{Label: "k"}, "nonce-abc", now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
jwk, ok := hdr["jwk"].(map[string]any)
if !ok {
t.Fatalf("attestation header missing jwk: %v", hdr)
}
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["use"] != "sig" {
t.Errorf("jwk = %v", jwk)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["nonce"] != "nonce-abc" {
t.Errorf("nonce = %v", claims["nonce"])
}
// jti, iat, exp are all required by the attestation spec.
iat, iatOK := claims["iat"].(float64)
exp, expOK := claims["exp"].(float64)
if !iatOK || !expOK || exp <= iat {
t.Errorf("claims iat/exp invalid: iat=%v exp=%v", claims["iat"], claims["exp"])
}
if jti, _ := claims["jti"].(string); jti == "" {
t.Error("claims jti empty")
}
// Signature verifies against the embedded key.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("attestation signature did not verify")
}
}
func TestSignAttestation_NilSigner(t *testing.T) {
if _, err := SignAttestation(context.Background(), nil, keysigner.KeyRef{}, "n", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestClaimFactories(t *testing.T) {
now := time.Unix(1700000000, 0)
a := attestationClaims("nonce-xyz", now)
if a["nonce"] != "nonce-xyz" || a["iat"] != now.Unix() {
t.Errorf("attestation claims = %v", a)
}
if a["exp"] != now.Add(attestationTTL).Unix() {
t.Errorf("attestation exp = %v, want %v", a["exp"], now.Add(attestationTTL).Unix())
}
if jti, _ := a["jti"].(string); jti == "" {
t.Error("attestation jti empty")
}
c := clientAssertionClaims("cli_app", "aud", now, time.Minute)
if c["exp"].(int64) != now.Add(time.Minute).Unix() {
t.Errorf("client_assertion exp = %v", c["exp"])
}
}

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/vfs"
)
@@ -38,10 +37,7 @@ type UATCallOptions struct {
AppId string
AppSecret string
Domain core.LarkBrand
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle for private_key_jwt
Signer keysigner.Signer // active signer for private_key_jwt
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
}
// UATStatus represents the status of a user access token.
@@ -65,9 +61,6 @@ func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
Signer: keysigner.Active(),
ErrOut: errOut,
}
}
@@ -78,7 +71,7 @@ var refreshLocks sync.Map
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
status := TokenStatus(stored)
@@ -93,7 +86,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
return "", err
}
if refreshed == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
return refreshed.AccessToken, nil
}
@@ -106,7 +99,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
// refreshWithLock acquires a file lock before attempting to refresh the token.
@@ -200,14 +193,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
ca := ClientAuth{AppID: opts.AppId, AppSecret: opts.AppSecret, AuthMethod: opts.AuthMethod, Signer: opts.Signer, KeyLabel: opts.KeyLabel}
usedAssertion, caErr := ca.applyClientAssertion(context.Background(), form, core.OpenAPIAudience(opts.Domain))
if caErr != nil {
return nil, caErr
}
if !usedAssertion {
form.Set("client_secret", opts.AppSecret)
}
form.Set("client_secret", opts.AppSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {

View File

@@ -38,23 +38,3 @@ func TestNewUATCallOptions(t *testing.T) {
t.Error("ErrOut not set correctly")
}
}
// TestNewUATCallOptions_PrivateKeyJWT verifies the auth-method fields propagate
// so the refresh path can mint a client_assertion instead of sending a secret.
func TestNewUATCallOptions_PrivateKeyJWT(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_pk",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "agent-key",
}
opts := NewUATCallOptions(cfg, &bytes.Buffer{})
if opts.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Errorf("AuthMethod = %q, want private_key_jwt", opts.AuthMethod)
}
if opts.KeyLabel != "agent-key" {
t.Errorf("KeyLabel = %q, want agent-key", opts.KeyLabel)
}
}

View File

@@ -16,6 +16,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// ─────────────────────────────────────────────────────────────────────────────
@@ -263,16 +264,19 @@ func TestWrapJSONResponseParseError_Nil(t *testing.T) {
// Cross-cutting: existing tests already in this file (kept and adjusted below).
// ─────────────────────────────────────────────────────────────────────────────
// TestWrapDoAPIError_UntypedErrorRoutesToNetwork pins that a plain untyped
// error (no embedded Problem, no JSON-decode chain) is NOT pass-through —
// only typed *errs.* values are. It routes to the network branch with the
// fallback transport subtype.
func TestWrapDoAPIError_UntypedErrorRoutesToNetwork(t *testing.T) {
got := WrapDoAPIError(errors.New("no access token available for user"))
// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy
// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier
// and is therefore not pass-through — only typed *errs.* values are.
// Legacy values fall through to the network/JSON branches based on their
// inner shape.
func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) {
// An *output.ErrAuth has no embedded Problem and no JSON-decode chain;
// it routes to the network branch with the fallback transport subtype.
got := WrapDoAPIError(output.ErrAuth("no access token available for user"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError for an untyped error, got %T (%v)", got, got)
t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got)
}
// Sanity: not silently re-classified as JSON-decode.
var ie *errs.InternalError

View File

@@ -19,9 +19,11 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
@@ -52,11 +54,16 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
if errors.As(err, &unavailableErr) {
return "", newTokenMissingError(as, unavailableErr)
}
// The credential chain already emits a typed *errs.AuthenticationError
// for the missing-UAT case (e.g. UAT refresh returned
// need_user_authorization), so it flows through unchanged: the
// outer-typed gate in cmd/root.go and the idempotent WrapDoAPIError
// both preserve its authentication category and exit 3.
// NeedAuthorizationError from the credential chain (e.g. UAT refresh
// returned need_user_authorization) must surface as typed
// AuthenticationError. Without this, WrapDoAPIError would wrap the
// raw err as NetworkError, and cmd/root.go's outer-typed gate would
// then skip PromoteAuthError — leaving the user with exit 4 and no
// auth-login hint instead of exit 3 typed authentication.
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return "", errcompat.PromoteAuthError(needAuthErr)
}
return "", err
}
if result.Token == "" {
@@ -113,22 +120,24 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
//
// SDK Do() failures are normalised through WrapDoAPIError so every caller
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
// each one remembering to wrap. WrapDoAPIError classifies a raw transport
// failure into a typed *errs.NetworkError / *errs.InternalError per the
// contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
// each one remembering to wrap. Today that wire shape is still the legacy
// *output.ExitError envelope (network / api_error); future framework-
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
// Errors that arrive already-classified (legacy *output.ExitError from
// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow
// through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
// WrapDoAPIError is idempotent on already-classified errors:
// the typed *errs.AuthenticationError that resolveAccessToken returns
// for missing tokens passes through with its auth category and exit 3
// intact, and any other typed *errs.* error from the credential chain
// survives the same way. Only stray untyped errors (raw fmt.Errorf)
// get the transport-or-internal fallback.
// the *output.ExitError that resolveAccessToken returns for missing
// tokens (via output.ErrAuth) passes through with its auth category
// and exit 3 intact, and any future typed *errs.* error from the
// credential chain survives the same way. Only stray untyped errors
// (raw fmt.Errorf) get the transport-or-internal fallback.
return nil, WrapDoAPIError(err)
}
if as.IsBot() {
@@ -153,7 +162,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
// any extra headers from opts are applied automatically.
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
// closed, and returned as an output.ErrNetwork — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
cfg := buildConfig(opts)
@@ -323,10 +332,10 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
//
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
// see a typed *errs.InternalError (invalid_response) instead of a bare
// fmt.Errorf — otherwise an empty or malformed page body would surface to the
// root handler as a plain-text "Error: ..." line and bypass the JSON stderr
// envelope contract.
// see an *output.ExitError envelope (api_error for malformed JSON, network
// for everything else) instead of a bare fmt.Errorf — otherwise an empty
// or malformed page body would surface to the root handler as a plain-text
// "Error: ..." line and bypass the JSON stderr envelope contract.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {

View File

@@ -528,7 +528,8 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
// the missing-token path of resolveAccessToken returns the typed
// *errs.AuthenticationError{Subtype: TokenMissing}.
// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy
// *output.ExitError envelope.
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
@@ -553,22 +554,24 @@ func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T
}
}
// needAuthTokenResolver mirrors the production credential chain: the
// missing-UAT case is constructed typed at the source (internal/auth) and
// carries the legacy *NeedAuthorizationError sentinel in its Cause chain. It
// must surface as a typed AuthenticationError and flow through resolveAccessToken
// and WrapDoAPIError unchanged (never mis-classified as NetworkError).
// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to
// exercise the P1 regression path: a credential chain that signals
// "user must re-authorize" must surface as typed AuthenticationError, not
// fall through to the generic err return which WrapDoAPIError would then
// wrap as NetworkError (the outer-typed dispatcher gate would then skip
// PromoteAuthError and the user would see exit 4 with no auth-login hint).
type needAuthTokenResolver struct {
userOpenID string
}
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, internalauth.NewNeedUserAuthorizationError(f.userOpenID)
return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID}
}
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
// pins that the typed missing-UAT error from the credential chain reaches the
// caller as a typed AuthenticationError with the marker and sentinel intact.
// is the codex P1 regression test: without this branch, the credential
// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError
// would mis-classify it as NetworkError.
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
@@ -674,7 +677,7 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is
// "internal".
// "internal", not the legacy "api_error".
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{

View File

@@ -41,26 +41,6 @@ type ResponseOptions struct {
CheckError func(result interface{}, identity core.Identity) error
}
// httpStatusError classifies an HTTP error response by status when the body
// carries no usable business error: 5xx → NetworkError (server tier), 404 →
// APIError/not_found, any other 4xx → APIError/unknown. Used wherever a
// status >= 400 must not be swallowed — a non-JSON body, an unparseable body,
// or a JSON body whose business code is 0.
func httpStatusError(status int, rawBody []byte) error {
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(rawBody)), 500)
if status >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", status, body).
WithCode(status)
}
subtype := errs.SubtypeUnknown
if status == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", status, body).
WithCode(status)
}
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
// 1. If Content-Type is JSON, check for business errors first (even with --output).
// 2. If --output is set and response is not a JSON error, save to file.
@@ -82,33 +62,33 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
}
}
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error
// directly instead of falling through to the binary-save path.
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
// instead of falling through to the binary-save path.
// 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error).
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
return httpStatusError(resp.StatusCode, resp.RawBody)
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
// JSON responses: always check for business errors before saving.
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
// An unparseable / empty body on an HTTP error (common with a
// missing Content-Type) must be classified by status, not reported
// as an internal decode failure, matching the non-JSON branch above.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result, identity); apiErr != nil {
return apiErr
}
// CheckResponse treats business code 0 as success, so a 4xx/5xx whose
// JSON body omits a non-zero code would otherwise be served as a
// successful result. Classify by HTTP status so it is never swallowed.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
if opts.OutputPath != "" {
// File downloads keep the existing raw-response scan path because the
// saved payload is the API response body, not the success envelope.

View File

@@ -372,76 +372,6 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
}
}
// TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed pins that an HTTP
// status error whose JSON body omits a non-zero business code (e.g. 400 +
// {"code":0,...}) still surfaces a typed error. CheckResponse treats code 0 as
// success, so without the HTTP-status fallback a 4xx would be served as a
// successful result and exit 0.
func TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed(t *testing.T) {
resp := newApiRespWithStatus(400, []byte(`{"code":0,"msg":"bad request"}`),
map[string]string{"Content-Type": "application/json"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got out=%q err=nil", out.String())
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("expected 'HTTP 400' in error, got: %s", err.Error())
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_404 pins that a 404 with an empty body
// and no Content-Type header — which falls into the JSON branch and fails to
// parse — is classified by HTTP status (api/not_found), not reported as an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte(""), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 with empty body and no Content-Type")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if apiErr != nil && apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want not_found", apiErr.Subtype)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_502 pins that a 5xx with a non-JSON
// body and no Content-Type is classified as a NetworkError by status, not an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 with non-JSON body and no Content-Type")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Errorf("expected *errs.NetworkError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
}
}
func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()

View File

@@ -4,13 +4,13 @@
package cmdpolicy_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -168,15 +168,10 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
}
}
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
// consumers rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
// Apply with the wrapped *output.ExitError exposes BOTH paths consumers
// rely on:
// 1. cmd/root.go's envelope writer (errors.As on *output.ExitError)
// 2. in-process consumers extracting the platform.CommandDeniedError
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
@@ -196,33 +191,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
t.Fatalf("denied command should return error")
}
// Path 1: typed-envelope view. The denial is a failed_precondition
// ValidationError so cmd/root.go renders the structured envelope and
// the process exits 2 (ExitValidation).
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
// Path 1: envelope-writer view.
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error chain must contain *output.ExitError, got %T", err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
if exitErr.Detail == nil {
t.Fatalf("ExitError.Detail required for envelope to render")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
if exitErr.Detail.Type != "command_denied" {
t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type)
}
// The policy metadata is folded into the Hint text: reason_code,
// policy_source, and rule_name must all be discoverable there.
if !strings.Contains(ve.Hint, "write_not_allowed") {
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
// JSON envelope shape: detail.reason_code must be present and
// match the closed enum.
detailMap, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail)
}
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
if detailMap["reason_code"] != "write_not_allowed" {
t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"])
}
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
if detailMap["policy_source"] != "plugin:secaudit" {
t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"])
}
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
// is preserved as the Cause so errors.As still reaches it.
// Path 2: in-process typed-error view.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain must expose *platform.CommandDeniedError")
@@ -230,6 +223,21 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" {
t.Errorf("CommandDeniedError = %+v", cd)
}
// Envelope round-trip sanity (the actual JSON cmd/root.go would emit).
var buf strings.Builder
output.WriteErrorEnvelope(&buf, exitErr, "user")
if !strings.Contains(buf.String(), `"type": "command_denied"`) {
t.Errorf("envelope JSON missing type=command_denied, got:\n%s", buf.String())
}
if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) {
t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String())
}
// Round-trip parse to verify it's well-formed JSON.
var parsed map[string]any
if err := json.Unmarshal([]byte(buf.String()), &parsed); err != nil {
t.Fatalf("envelope JSON malformed: %v\n%s", err, buf.String())
}
}
// Regression: a pure parent group carrying AnnotationPureGroup must be

View File

@@ -6,8 +6,8 @@ package cmdpolicy
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
)
// Apply walks the command tree and installs denyStubs for every path in
@@ -24,11 +24,12 @@ import (
// cobra would intercept the call
// with "missing required flag"
// before we can return our error
// 3. cmd.RunE = denyStub(denial) -- returns a typed
// *errs.ValidationError so
// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
// cmd/root.go's envelope writer
// emits structured JSON; the
// wrapped error chain still
// emits structured JSON (with
// error.type = denial.Layer and
// detail.reason_code = ReasonCode);
// the wrapped error chain still
// exposes *platform.CommandDeniedError
// via errors.As for in-process
// consumers
@@ -111,17 +112,42 @@ func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError
}
}
// BuildDenialError is the default typed error for user-layer denials:
// Message comes from CommandDeniedError.Error(); the policy layer, source,
// rule name, and reason code are folded into the Hint. The
// *platform.CommandDeniedError is preserved as the Cause so errors.As
// works for in-process consumers.
func BuildDenialError(path string, d Denial) *errs.ValidationError {
// DenialDetailMap is the canonical detail.* shape every `command_denied`
// envelope shares (see docs/extension/reason-codes.md). Use it as
// ErrDetail.Detail when constructing an envelope outside BuildDenialError.
func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
return map[string]any{
"path": cd.Path,
"layer": cd.Layer,
"policy_source": cd.PolicySource,
"rule_name": cd.RuleName,
"reason_code": cd.ReasonCode,
"reason": cd.Reason,
}
}
// BuildDenialError is the default envelope for user-layer denials:
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
// need a custom Message or an independent Hint (strict-mode) should
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
//
// Deprecated: BuildDenialError produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — denial signals should move to a typed *errs.XxxError (a dedicated
// typed Error for policy denial is tracked for the cmdpolicy migration PR).
// This helper is retained only while existing call sites are migrated; it
// will be removed once they have moved to the typed surface.
func BuildDenialError(path string, d Denial) *output.ExitError {
cd := CommandDeniedFromDenial(path, d)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", cd.Error()).
WithHint("denied by %s policy (source %s, rule %q, reason_code %s); adjust the policy configuration to allow this command",
cd.Layer, cd.PolicySource, cd.RuleName, cd.ReasonCode).
WithCause(cd)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: cd.Error(),
Detail: DenialDetailMap(cd),
},
Err: cd,
}
}
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
@@ -195,9 +221,9 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
denial := d // capture by value for the closure
cmd.RunE = func(c *cobra.Command, args []string) error {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// distinction ("policy" vs "strict_mode") for debugging.
// error.type is the user-facing semantic ("a command was denied by
// policy"). detail.layer carries the implementation distinction
// ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial)
}
// Clear any pre-existing Run hook: cobra prefers RunE when both are

View File

@@ -9,9 +9,9 @@
// aggregation), which the Apply step consumes to install denyStubs.
//
// This package only implements the user-layer half. Strict-mode is handled
// by cmd/prune.go, which produces typed validation errors of the same shape
// (failed_precondition, *platform.CommandDeniedError preserved as Cause) so
// external agents see a uniform envelope regardless of which layer rejected
// by cmd/prune.go, which produces command_denied envelopes of the same
// shape via BuildDenialError so external agents can dispatch on
// detail.layer / reason_code uniformly regardless of which layer rejected
// the call.
package cmdpolicy

View File

@@ -10,9 +10,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/output"
)
// The envelope's policy_source must never leak the absolute home path.
@@ -39,26 +39,25 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected denial *errs.ValidationError, got %T %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected denial ExitError, got %v", err)
}
// The policy source is folded into the Hint as "yaml" -- the bare
// kind, never the absolute path.
if !strings.Contains(ve.Hint, "source yaml") {
t.Errorf("hint must carry policy_source %q (no path leak), got %q", "yaml", ve.Hint)
detail := exitErr.Detail.Detail.(map[string]any)
src, _ := detail["policy_source"].(string)
if src != "yaml" {
t.Errorf("policy_source = %q, want %q (no path leak)", src, "yaml")
}
// rule_name carries the disambiguating identifier.
if !strings.Contains(ve.Hint, "my-readonly-rule") {
t.Errorf("hint must carry rule_name my-readonly-rule, got %q", ve.Hint)
if detail["rule_name"] != "my-readonly-rule" {
t.Errorf("rule_name = %v, want my-readonly-rule", detail["rule_name"])
}
// Direct privacy probe: the absolute home path must not appear
// anywhere in the user-facing message OR hint text.
if strings.Contains(ve.Message, "/Users/alice") {
t.Errorf("error message must not leak '/Users/alice', got %q", ve.Message)
}
if strings.Contains(ve.Hint, "/Users/alice") {
t.Errorf("error hint must not leak '/Users/alice', got %q", ve.Hint)
// Direct probe: the absolute path must not appear anywhere in the
// envelope detail (key OR value).
for k, v := range detail {
if strings.Contains(k, "/Users/alice") || strings.Contains(asString(v), "/Users/alice") {
t.Errorf("envelope detail must not leak '/Users/alice', found in %s = %v", k, v)
}
}
}
@@ -81,14 +80,17 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError")
}
// The plugin name IS surfaced (in-binary, part of the contract): it
// must appear in the Hint so an integrator debugging a denial knows
// which plugin fired.
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
detail := exitErr.Detail.Detail.(map[string]any)
if detail["policy_source"] != "plugin:secaudit" {
t.Errorf("policy_source = %v, want plugin:secaudit", detail["policy_source"])
}
}
func asString(v any) string {
s, _ := v.(string)
return s
}

View File

@@ -4,22 +4,39 @@
package cmdutil
import (
"github.com/larksuite/cli/errs"
"fmt"
"github.com/larksuite/cli/internal/output"
)
// RequireConfirmation constructs a typed *errs.ConfirmationRequiredError
// (exit code ExitConfirmationRequired) carrying the risk level and action as
// typed extension fields. Used by both shortcut and service command execution
// paths when a statically high-risk-write operation has not been confirmed
// with --yes.
// RequireConfirmation constructs a confirmation_required error with exit code
// ExitConfirmationRequired and a structured Risk envelope. Used by both
// shortcut and service command execution paths when a statically
// high-risk-write operation has not been confirmed with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
// Deprecated: RequireConfirmation produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — confirmation-required signals should move to typed
// *errs.ConfirmationRequiredError carrying the same agent-protocol metadata
// (level/action) as typed extension fields. This helper is retained only
// while existing call sites are migrated; it will be removed once they have
// moved to the typed surface.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
return &output.ExitError{
Code: output.ExitConfirmationRequired,
Detail: &output.ErrDetail{
Type: "confirmation_required",
Message: fmt.Sprintf("%s requires confirmation", action),
Hint: "add --yes to confirm",
Risk: &output.RiskDetail{
Level: RiskHighRiskWrite,
Action: action,
},
},
}
}

View File

@@ -9,50 +9,53 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestRequireConfirmation_TypedShape(t *testing.T) {
func TestRequireConfirmation_EnvelopeShape(t *testing.T) {
err := RequireConfirmation("drive +delete")
if err == nil {
t.Fatal("expected non-nil error")
}
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if cre.Category != errs.CategoryConfirmation {
t.Errorf("Category = %q, want %q", cre.Category, errs.CategoryConfirmation)
if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitConfirmationRequired)
}
if cre.Subtype != errs.SubtypeConfirmationRequired {
t.Errorf("Subtype = %q, want %q", cre.Subtype, errs.SubtypeConfirmationRequired)
if exitErr.Detail == nil {
t.Fatal("Detail is nil")
}
if got := output.ExitCodeOf(err); got != output.ExitConfirmationRequired {
t.Errorf("ExitCodeOf = %d, want %d", got, output.ExitConfirmationRequired)
d := exitErr.Detail
if d.Type != "confirmation_required" {
t.Errorf("Type = %q, want confirmation_required", d.Type)
}
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
if !strings.Contains(d.Message, "drive +delete") || !strings.Contains(d.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", d.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
if d.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", d.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
if d.Risk == nil {
t.Fatal("Risk is nil")
}
if cre.Action != "drive +delete" {
t.Errorf("Action = %q, want drive +delete", cre.Action)
if d.Risk.Level != "high-risk-write" {
t.Errorf("Risk.Level = %q, want high-risk-write", d.Risk.Level)
}
if d.Risk.Action != "drive +delete" {
t.Errorf("Risk.Action = %q, want drive +delete", d.Risk.Action)
}
}
func TestRequireConfirmation_JSONShape(t *testing.T) {
err := RequireConfirmation("mail +send")
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
raw, mErr := json.Marshal(cre)
raw, mErr := json.Marshal(exitErr.Detail)
if mErr != nil {
t.Fatalf("marshal: %v", mErr)
}
@@ -67,14 +70,18 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
if back["risk"] != "high-risk-write" {
t.Errorf("risk in JSON = %v", back["risk"])
risk, ok := back["risk"].(map[string]interface{})
if !ok {
t.Fatalf("risk block missing in JSON: %s", raw)
}
if back["action"] != "mail +send" {
t.Errorf("action in JSON = %v", back["action"])
if risk["level"] != "high-risk-write" {
t.Errorf("risk.level in JSON = %v", risk["level"])
}
if risk["action"] != "mail +send" {
t.Errorf("risk.action in JSON = %v", risk["action"])
}
// Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak.
if _, has := back["upgraded_by"]; has {
if _, has := risk["upgraded_by"]; has {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}

View File

@@ -8,7 +8,6 @@ import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
@@ -108,9 +107,9 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
if err == nil {
t.Fatal("Config() error = nil, want non-nil")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("Config() error type = %T, want *errs.ConfigError", err)
t.Fatalf("Config() error type = %T, want *core.ConfigError", err)
}
if cfgErr.Message != `profile "missing" not found` {
t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`)

View File

@@ -10,8 +10,8 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -42,41 +42,26 @@ func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpM
_, filePath, isStdin := ParseFileFlag(file, "file")
if !isStdin && filePath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: empty file path").
WithParam("--file")
return output.ErrValidation("--file: empty file path")
}
if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --output are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --output"},
errs.InvalidParam{Name: "--output", Reason: "mutually exclusive with --file"},
)
return output.ErrValidation("--file and --output are mutually exclusive")
}
if pageAll {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --page-all are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "mutually exclusive with --file"},
)
return output.ErrValidation("--file and --page-all are mutually exclusive")
}
if isStdin && data == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --data cannot both read from stdin").WithParams(
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--data", Reason: "only one flag may read from stdin"},
)
return output.ErrValidation("--file and --data cannot both read from stdin")
}
if isStdin && params == "-" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --params cannot both read from stdin").WithParams(
errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"},
errs.InvalidParam{Name: "--params", Reason: "only one flag may read from stdin"},
)
return output.ErrValidation("--file and --params cannot both read from stdin")
}
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file requires POST, PUT, PATCH, or DELETE method").
WithParam("--file").
WithHint("file upload only applies to write methods; remove --file for read methods")
return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
}
return nil
@@ -98,35 +83,25 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
if isStdin {
if stdin == nil {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "--file: stdin is not available").
WithParam("--file").
WithHint("pipe the file content to stdin, or pass a file path instead of \"-\"")
return nil, output.ErrValidation("--file: stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read stdin: %v", err).
WithParam("--file").
WithCause(err)
return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
}
if len(data) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: stdin is empty").
WithParam("--file").
WithHint("pipe non-empty file content to stdin")
return nil, output.ErrValidation("--file: stdin is empty")
}
fd.AddFile(fieldName, bytes.NewReader(data))
} else {
f, err := fileIO.Open(filePath)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot open file: %s", filePath).
WithParam("--file").
WithCause(err)
return nil, output.ErrValidation("cannot open file: %s", filePath)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read %s: %v", filePath, err).
WithParam("--file").
WithCause(err)
return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
}
fd.AddFile(fieldName, bytes.NewReader(data))
}

View File

@@ -5,49 +5,14 @@ package cmdutil
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// failingReader always errors on Read, to exercise stdin read-failure paths.
type failingReader struct{ err error }
func (r *failingReader) Read([]byte) (int, error) { return 0, r.err }
// requireFileValidationError asserts err is a typed *errs.ValidationError with
// the expected subtype, exit code 2 (legacy ErrValidation parity), and a
// param diagnostic referencing --file (either Param or one of Params).
func requireFileValidationError(t *testing.T, err error, wantSubtype errs.Subtype) *errs.ValidationError {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation)
}
mentionsFile := valErr.Param == "--file"
for _, p := range valErr.Params {
if p.Name == "--file" {
mentionsFile = true
}
}
if !mentionsFile {
t.Errorf("expected --file in Param/Params, got Param=%q Params=%v", valErr.Param, valErr.Params)
}
return valErr
}
func TestParseFileFlag(t *testing.T) {
tests := []struct {
name string
@@ -257,7 +222,6 @@ func TestValidateFileFlag(t *testing.T) {
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
})
}
}
@@ -284,19 +248,6 @@ func TestBuildFormdata(t *testing.T) {
if !strings.Contains(err.Error(), "stdin is not available") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is not available")
}
requireFileValidationError(t, err, errs.SubtypeFailedPrecondition)
})
t.Run("stdin read failure", func(t *testing.T) {
readErr := errors.New("pipe closed")
_, err := BuildFormdata(fio, "file", "", true, &failingReader{err: readErr}, nil)
if err == nil {
t.Fatal("expected error for failing stdin reader")
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
if !errors.Is(err, readErr) {
t.Error("underlying read error not reachable via errors.Is; WithCause missing")
}
})
t.Run("stdin empty", func(t *testing.T) {
@@ -308,7 +259,6 @@ func TestBuildFormdata(t *testing.T) {
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is empty")
}
requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
})
t.Run("file open success", func(t *testing.T) {
@@ -339,10 +289,6 @@ func TestBuildFormdata(t *testing.T) {
if !strings.Contains(err.Error(), "cannot open file:") {
t.Errorf("error = %q, want containing %q", err.Error(), "cannot open file:")
}
valErr := requireFileValidationError(t, err, errs.SubtypeInvalidArgument)
if valErr.Cause == nil {
t.Error("expected the os open error attached as Cause")
}
})
t.Run("dataJSON fields added", func(t *testing.T) {

View File

@@ -42,16 +42,6 @@ func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// StdoutIsTerminal reports whether Out is an interactive terminal. Unlike
// IsTerminal — which reflects stdin and drives prompt decisions — this is the
// correct check for OUTPUT formatting: `cmd | jq` must still emit machine output
// from an interactive shell (stdin is a TTY there, but stdout is the pipe).
// Buffers (tests) and redirects are not *os.File terminals, so they yield false.
func (s *IOStreams) StdoutIsTerminal() bool {
f, ok := s.Out.(*os.File)
return ok && term.IsTerminal(int(f.Fd()))
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
//
//nolint:forbidigo // entry point for real stdio

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestStdoutIsTerminal(t *testing.T) {
// Buffer-backed output (tests, captured output) is never a terminal.
if (&IOStreams{Out: &bytes.Buffer{}}).StdoutIsTerminal() {
t.Error("bytes.Buffer Out should not be a terminal")
}
// An os.Pipe write end is an *os.File but not a terminal — mirrors `cmd | jq`,
// the case the stdin-based IsTerminal would get wrong.
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
if (&IOStreams{Out: w}).StdoutIsTerminal() {
t.Error("os.Pipe Out should not be a terminal")
}
}

View File

@@ -7,8 +7,8 @@ import (
"encoding/json"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
@@ -22,18 +22,14 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
}
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data: %s", err).
WithParam("--data").
WithCause(err)
return nil, output.ErrValidation("--data: %s", err)
}
if resolved == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON format").
WithParam("--data").
WithCause(err)
return nil, output.ErrValidation("--data invalid JSON format")
}
return body, nil
}
@@ -45,18 +41,14 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", label, err).
WithParam(label).
WithCause(err)
return nil, output.ErrValidation("%s: %s", label, err)
}
if resolved == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s invalid format, expected JSON object", label).
WithParam(label).
WithCause(err)
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
if result == nil {
// `null` unmarshals into a nil map without error; normalize it so the

View File

@@ -3,40 +3,9 @@
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// requireJSONInputValidationError asserts err is a typed *errs.ValidationError
// with subtype invalid_argument, exit code 2 (legacy ErrValidation parity),
// and the offending flag recorded as Param.
func requireJSONInputValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != wantParam {
t.Errorf("param = %q, want %q", valErr.Param, wantParam)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation)
}
if valErr.Cause == nil {
t.Error("expected the underlying parse/resolve error attached as Cause")
}
}
import "testing"
func TestParseOptionalBody(t *testing.T) {
fio := &localfileio.LocalFileIO{}
tests := []struct {
name string
method string
@@ -51,23 +20,18 @@ func TestParseOptionalBody(t *testing.T) {
{"PATCH valid", "PATCH", `"hello"`, false, false},
{"DELETE valid", "DELETE", `{"id":"1"}`, false, false},
{"POST invalid json", "POST", `{bad}`, true, true},
{"POST unreadable @file", "POST", "@/nonexistent/body.json", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data, nil, fio)
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
requireJSONInputValidationError(t, err, "--data")
return
}
if tt.wantNil && got != nil {
t.Errorf("ParseOptionalBody() = %v, want nil", got)
}
if !tt.wantNil && got == nil {
if !tt.wantNil && !tt.wantErr && got == nil {
t.Error("ParseOptionalBody() = nil, want non-nil")
}
})
@@ -75,7 +39,6 @@ func TestParseOptionalBody(t *testing.T) {
}
func TestParseJSONMap(t *testing.T) {
fio := &localfileio.LocalFileIO{}
tests := []struct {
name string
input string
@@ -88,20 +51,15 @@ func TestParseJSONMap(t *testing.T) {
{"valid json", `{"a":"1","b":"2"}`, "--params", 2, false},
{"invalid json", `{bad}`, "--params", 0, true},
{"json array", `[1,2]`, "--data", 0, true},
{"unreadable @file", "@/nonexistent/params.json", "--params", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label, nil, fio)
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
requireJSONInputValidationError(t, err, tt.label)
return
}
if len(got) != tt.wantLen {
if !tt.wantErr && len(got) != tt.wantLen {
t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen)
}
// A successful parse must yield a non-nil, writable map: callers

View File

@@ -6,8 +6,8 @@ package cmdutil
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
// ParseLangFlag validates and canonicalizes a --lang value, shared by config
@@ -19,10 +19,9 @@ func ParseLangFlag(raw string) (i18n.Lang, error) {
}
lang, ok := i18n.Parse(raw)
if !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
return "", output.ErrValidation(
"invalid --lang %q; valid values: %s",
raw, strings.Join(i18n.Codes(), ", ")).
WithParam("--lang")
raw, strings.Join(i18n.Codes(), ", "))
}
return lang, nil
}

View File

@@ -11,9 +11,9 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -36,13 +36,6 @@ type AppUser struct {
UserName string `json:"userName"`
}
// Auth methods for app credentials. An empty AppConfig.AuthMethod means the
// default, client_secret.
const (
AuthMethodClientSecret = "client_secret" // app_id + app_secret
AuthMethodPrivateKeyJWT = "private_key_jwt" // TEE-signed client_assertion; no app secret
)
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
type AppConfig struct {
Name string `json:"name,omitempty"`
@@ -53,15 +46,6 @@ type AppConfig struct {
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
// AuthMethod selects how tokens are minted. Empty == AuthMethodClientSecret
// (back-compat). AuthMethodPrivateKeyJWT uses a TEE-held key (see KeyRef) to
// sign client_assertion JWTs instead of sending an app secret.
AuthMethod string `json:"authMethod,omitempty"`
// KeyRef references the non-exportable signing key for private_key_jwt.
// Source is "tee" and ID is the backend key label; the actual key never
// leaves the secure backend, so this is a handle, not secret material.
KeyRef *SecretRef `json:"keyRef,omitempty"`
}
// ProfileName returns the display name for this app config.
@@ -177,9 +161,7 @@ type CliConfig struct {
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
AuthMethod string // "" == client_secret; AuthMethodPrivateKeyJWT
KeyLabel string // resolved TEE key handle for private_key_jwt
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
@@ -205,12 +187,6 @@ func GetConfigPath() string {
return filepath.Join(GetConfigDir(), "config.json")
}
// ErrMalformedConfig marks a config-load failure caused by malformed file
// content (unparseable JSON, structurally empty) rather than a missing or
// unreadable file. Callers classify with errors.Is rather than sniffing the
// message text.
var ErrMalformedConfig = errors.New("malformed config")
// LoadMultiAppConfig loads multi-app config from disk.
func LoadMultiAppConfig() (*MultiAppConfig, error) {
data, err := vfs.ReadFile(GetConfigPath())
@@ -220,10 +196,10 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) {
var multi MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return nil, fmt.Errorf("invalid config format: %w: %w", ErrMalformedConfig, err)
return nil, fmt.Errorf("invalid config format: %w", err)
}
if len(multi.Apps) == 0 {
return nil, fmt.Errorf("invalid config format: no apps: %w", ErrMalformedConfig)
return nil, fmt.Errorf("invalid config format: no apps")
}
return &multi, nil
}
@@ -261,62 +237,37 @@ func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string)
func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
app := raw.CurrentAppConfig(profileOverride)
if app == nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found", profileOverride).
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
}
// Validate the auth method first so a malformed profile fails here rather
// than silently degrading to client_secret (unknown method) or failing later
// at token-signing. Empty stays empty — downstream treats it as client_secret
// (back-compat).
switch app.AuthMethod {
case "", AuthMethodClientSecret, AuthMethodPrivateKeyJWT:
default:
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q", app.AuthMethod).
WithHint("supported: %s, %s (empty defaults to %s)", AuthMethodClientSecret, AuthMethodPrivateKeyJWT, AuthMethodClientSecret)
}
// private_key_jwt carries no secret: validate the key handle and skip secret
// resolution entirely, so a stale/broken AppSecret ref never produces a
// confusing secret-resolution error for an otherwise-valid pkjwt profile.
var secret string
if app.AuthMethod == AuthMethodPrivateKeyJWT {
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "private_key_jwt requires a key handle (keyRef) but none is configured").
WithHint("re-run: lark-cli config init --new --auth-method private_key_jwt")
}
} else {
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}
var resolveErr error
secret, resolveErr = ResolveSecretInput(app.AppSecret, kc)
if resolveErr != nil {
if errs.IsTyped(resolveErr) {
return nil, resolveErr
}
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(resolveErr) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "%s", resolveErr.Error()).WithCause(resolveErr)
return nil, &ConfigError{
Code: 3,
Type: "config",
Message: fmt.Sprintf("profile %q not found", profileOverride),
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
}
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, &ConfigError{Code: 3, Type: "config",
Message: "appId and appSecret keychain key are out of sync",
Hint: err.Error()}
}
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return nil, exitErr
}
return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
}
cfg := &CliConfig{
ProfileName: app.ProfileName(),
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
Lang: app.Lang,
AuthMethod: app.AuthMethod,
DefaultAs: app.DefaultAs,
}
if app.KeyRef != nil {
cfg.KeyLabel = app.KeyRef.ID
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId
cfg.UserName = app.Users[0].UserName
@@ -336,8 +287,7 @@ func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (
return nil, err
}
if cfg.UserOpenId == "" {
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
WithHint("run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.")
return nil, &ConfigError{Code: 3, Type: "auth", Message: "not logged in", Hint: "run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login."}
}
return cfg, nil
}

View File

@@ -8,7 +8,6 @@ import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/keychain"
)
@@ -104,7 +103,7 @@ func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
if err == nil {
t.Fatal("expected error for mismatched appId and appSecret keychain key")
}
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
@@ -133,108 +132,6 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
}
}
// TestResolveConfigFromMulti_RejectsUnknownAuthMethod ensures an unsupported
// authMethod fails at resolution rather than silently degrading to client_secret.
func TestResolveConfigFromMulti_RejectsUnknownAuthMethod(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
AuthMethod: "bogus_method",
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for unknown authMethod")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
}
// TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef ensures private_key_jwt
// without a key handle fails at resolution rather than later at token-signing.
func TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{}, // private_key_jwt carries no app secret
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
// KeyRef intentionally nil
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for private_key_jwt without keyRef")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
// Control: same config WITH a keyRef resolves cleanly and sets KeyLabel.
raw.Apps[0].KeyRef = &SecretRef{Source: "tee", ID: "larksuite-cli-agent"}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error with keyRef present: %v", err)
}
if cfg.KeyLabel != "larksuite-cli-agent" {
t.Errorf("KeyLabel = %q, want larksuite-cli-agent", cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTSkipsSecretResolution ensures a private_key_jwt
// profile that carries a stale/broken AppSecret ref still resolves cleanly: the
// auth method is judged before any secret handling, so the stale ref is ignored
// instead of producing a confusing secret-resolution failure.
func TestResolveConfigFromMulti_PKJWTSkipsSecretResolution(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{{
AppId: "cli_pk",
// Stale keychain ref whose ID does not match appId — would trip
// ValidateSecretKeyMatch / ResolveSecretInput if it were reached.
AppSecret: SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_OTHER"}},
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: &SecretRef{Source: "tee", ID: "agent-key"},
Users: []AppUser{},
}},
}
cfg, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err != nil {
t.Fatalf("pkjwt with stale secret ref must skip secret resolution, got %v", err)
}
if cfg.AuthMethod != AuthMethodPrivateKeyJWT || cfg.KeyLabel != "agent-key" {
t.Errorf("got authMethod=%q keyLabel=%q", cfg.AuthMethod, cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef ensures the stricter keyRef
// check (Source=="tee" && ID!="") rejects malformed handles.
func TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef(t *testing.T) {
for i, ref := range []*SecretRef{
{Source: "keychain", ID: "x"}, // wrong source
{Source: "tee", ID: ""}, // empty id
} {
raw := &MultiAppConfig{Apps: []AppConfig{{
AppId: "cli_pk", Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: ref, Users: []AppUser{},
}}}
if _, err := ResolveConfigFromMulti(raw, stubKeychain{}, ""); err == nil {
t.Errorf("case %d: expected ConfigError for bad keyRef", i)
}
}
}
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
@@ -280,7 +177,7 @@ func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T
t.Fatal("expected error (keychain entry not found), got nil")
}
// The error should come from keychain resolution, NOT from our mismatch check.
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if errors.As(err, &cfgErr) {
if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
t.Fatal("error came from mismatch check, but keys should match")

22
internal/core/errors.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import "fmt"
// ConfigError is a structured error from config resolution.
// It carries enough information for main.go to convert it into an output.ExitError.
type ConfigError struct {
Code int // exit code: 3 (config errors share the auth exit code)
Type string // "config" or "auth"
Message string
Hint string
}
func (e *ConfigError) Error() string {
if e.Hint != "" {
return fmt.Sprintf("%s\n %s", e.Message, e.Hint)
}
return e.Message
}

View File

@@ -5,20 +5,10 @@ package core
import (
"errors"
"fmt"
"os"
"github.com/larksuite/cli/errs"
)
// isMalformedConfigError reports whether a config load failure indicates a
// malformed file (unparseable / structurally empty) rather than an absent or
// inaccessible one. Malformed files map to the invalid_config subtype so the
// user is told to fix the file instead of re-running init. Detection is by
// ErrMalformedConfig sentinel, not message text.
func isMalformedConfigError(err error) bool {
return errors.Is(err, ErrMalformedConfig)
}
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
// configured vs. couldn't read" disambiguation that every config-required
// command should use:
@@ -37,15 +27,14 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
return nil, NotConfiguredError()
}
// Surface the real cause (parse error, permission denied, etc.)
// so the user can fix the broken file. A malformed file is
// invalid_config; anything else (permission denied, etc.) is
// not_configured. Both stay on the typed structured-envelope path
// at the root command's error sink.
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
// so the user can fix the broken file. Wrapping as ConfigError
// keeps it on the standard structured-envelope path at the root
// command's error sink.
return nil, &ConfigError{
Code: 3,
Type: "config",
Message: fmt.Sprintf("failed to load config: %v", err),
}
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
return nil, NotConfiguredError()
@@ -81,14 +70,19 @@ const (
func NotConfiguredError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
WithHint("%s", localInitHint)
return &ConfigError{
Code: 3,
Type: "config",
Message: "not configured",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 3,
Type: ws.Display(),
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
Hint: agentBindHint,
}
// Agent workspace: the workspace name appears only in the message, never
// in the wire subtype, which stays not_configured.
return errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display()).
WithHint("%s", agentBindHint)
}
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
@@ -110,10 +104,17 @@ func reconfigureHint() string {
func NoActiveProfileError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
WithHint("%s", localInitHint)
return &ConfigError{
Code: 3,
Type: "config",
Message: "no active profile",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 3,
Type: ws.Display(),
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
Hint: agentBindHint,
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"no active profile in %s workspace", ws.Display()).
WithHint("%s", agentBindHint)
}

View File

@@ -8,8 +8,6 @@ import (
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
@@ -26,15 +24,12 @@ func TestNotConfiguredError_Local(t *testing.T) {
SetCurrentWorkspace(WorkspaceLocal)
err := NotConfiguredError()
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Category != errs.CategoryConfig || cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("category/subtype = %q/%q, want config/not_configured", cfgErr.Category, cfgErr.Subtype)
}
if cfgErr.Message != "not configured" {
t.Errorf("message = %q, want %q", cfgErr.Message, "not configured")
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Errorf("unexpected detail: %+v", cfgErr)
}
if !strings.Contains(cfgErr.Hint, "config init --new") {
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
@@ -49,17 +44,12 @@ func TestNotConfiguredError_OpenClaw(t *testing.T) {
SetCurrentWorkspace(WorkspaceOpenClaw)
err := NotConfiguredError()
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *ConfigError", err)
}
// The wire subtype stays not_configured; the workspace name only appears
// in the message, never in the typed taxonomy.
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
// Hint must point at --help (read first, confirm with user, then bind),
// NOT a directly-executable bind command — binding is policy-laden
@@ -77,15 +67,12 @@ func TestNotConfiguredError_Hermes(t *testing.T) {
SetCurrentWorkspace(WorkspaceHermes)
err := NotConfiguredError()
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
@@ -97,9 +84,9 @@ func TestNoActiveProfileError_Local(t *testing.T) {
SetCurrentWorkspace(WorkspaceLocal)
err := NoActiveProfileError()
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Message != "no active profile" {
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
@@ -111,9 +98,9 @@ func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
SetCurrentWorkspace(WorkspaceOpenClaw)
err := NoActiveProfileError()
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *ConfigError", err)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
@@ -149,12 +136,9 @@ func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Message != "not configured" {
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
@@ -180,13 +164,9 @@ func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
if err == nil {
t.Fatal("expected error for corrupt config")
}
var cfgErr *errs.ConfigError
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// A malformed file maps to invalid_config, not not_configured.
if cfgErr.Subtype != errs.SubtypeInvalidConfig {
t.Errorf("subtype = %q, want invalid_config", cfgErr.Subtype)
t.Fatalf("error type = %T, want *ConfigError", err)
}
if !strings.Contains(cfgErr.Message, "failed to load config") {
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
@@ -198,8 +178,4 @@ func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
}
// The underlying parse failure stays reachable through the unwrap chain.
if cfgErr.Cause == nil {
t.Error("Cause must wrap the underlying load error for errors.Is/Unwrap")
}
}

View File

@@ -3,8 +3,6 @@
package core
import "strings"
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// Any other string is treated as a custom base URL.
@@ -62,10 +60,3 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
func ResolveOpenBaseURL(brand LarkBrand) string {
return ResolveEndpoints(brand).Open
}
// OpenAPIAudience returns the client_assertion `aud` value for the brand: the
// bare Open API host per the App Authentication JWT spec — "open.feishu.cn" or
// "open.larksuite.com" — not the full token endpoint URL.
func OpenAPIAudience(brand LarkBrand) string {
return strings.TrimPrefix(ResolveOpenBaseURL(brand), "https://")
}

View File

@@ -57,12 +57,3 @@ func TestResolveOpenBaseURL(t *testing.T) {
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
}
}
func TestOpenAPIAudience(t *testing.T) {
if got := OpenAPIAudience(BrandFeishu); got != "open.feishu.cn" {
t.Errorf("OpenAPIAudience(feishu) = %q, want open.feishu.cn", got)
}
if got := OpenAPIAudience(BrandLark); got != "open.larksuite.com" {
t.Errorf("OpenAPIAudience(lark) = %q, want open.larksuite.com", got)
}
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/keychain"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/keysigner"
)
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
@@ -176,23 +175,6 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil {
return nil, err
}
// private_key_jwt apps have no app secret: mint via the jwt-bearer grant
// using a TEE-signed client_assertion instead.
if acct.AuthMethod == core.AuthMethodPrivateKeyJWT {
signer := keysigner.Active()
if signer == nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
"profile uses private_key_jwt but no TEE key signer is available on this build").
WithHint("install a build with the platform key-signer extension, or reconfigure the app to use an app secret")
}
token, err := FetchTATWithAssertion(ctx, httpClient, acct.Brand, acct.AppID, signer, acct.KeyLabel)
if err != nil {
return nil, err
}
return &TokenResult{Token: token}, nil
}
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
if err != nil {
return nil, err

View File

@@ -11,13 +11,8 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
@@ -105,96 +100,3 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
}
// FetchTATWithAssertion mints a tenant access token for a private_key_jwt app via
// the RFC 7523 jwt-bearer grant: it signs a short-lived client_assertion with the
// TEE-held key and posts it to the unified OAuth token endpoint, replacing the
// app_secret entirely.
//
// The unified v2 token endpoint returns the minted token as access_token
// (tenant_access_token is accepted as a fallback).
func FetchTATWithAssertion(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) (string, error) {
if signer == nil {
return "", fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
}
ep := core.ResolveEndpoints(brand)
endpoint := ep.Open + auth.PathOAuthTokenV2
assertion, err := jwt.SignClientAssertion(ctx, signer, keysigner.KeyRef{Label: keyLabel}, clientID, core.OpenAPIAudience(brand), time.Now())
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("client_id", clientID)
form.Set("client_assertion_type", jwt.ClientAssertionType)
form.Set("client_assertion", assertion)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read token response: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
AccessToken string `json:"access_token"`
TenantAccessToken string `json:"tenant_access_token"`
}
_ = json.Unmarshal(body, &result) // best-effort; error body may not be JSON
token := result.AccessToken
if token == "" {
token = result.TenantAccessToken
}
if resp.StatusCode == http.StatusOK && token != "" && result.Error == "" && result.Code == 0 {
return token, nil
}
// Surface the server's reason, preferring the OAuth `error` code (e.g.
// unauthorized_client) which is more diagnostic than the description alone.
detail := result.ErrorDescription
if detail == "" {
detail = result.Msg
}
if detail == "" {
detail = strings.TrimSpace(string(body))
}
if result.Error != "" {
return "", classifyAssertionError(result.Error, resp.StatusCode, detail)
}
return "", fmt.Errorf("token endpoint HTTP %d (code=%d): %s", resp.StatusCode, result.Code, detail)
}
// classifyAssertionError maps the OAuth token endpoint's `error` field to a
// typed or untyped error. Only deterministic client-credential rejections get a
// typed errs.ConfigError (so runProbePKJWT can tell "this key is not bound to
// this app" apart from upstream noise); every other error (e.g.
// temporarily_unavailable) stays untyped and is swallowed by the probe. detail
// carries only the server's error_description / msg / body text — it never
// echoes the client_assertion or private key (the assertion lives only in the
// request form).
func classifyAssertionError(oauthError string, httpStatus int, detail string) error {
switch oauthError {
case "invalid_client", "unauthorized_client", "invalid_grant":
return errs.NewConfigError(errs.SubtypeInvalidClient,
"token endpoint rejected the key (%s): %s", oauthError, detail)
default:
return fmt.Errorf("token endpoint HTTP %d (%s): %s", httpStatus, oauthError, detail)
}
}

View File

@@ -5,24 +5,15 @@ package credential
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// stubRoundTripper lets us assert request shape and return canned responses.
@@ -316,147 +307,3 @@ func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}
// fakeTATSigner is a real in-memory ECDSA P-256 signer for assertion tests.
type fakeTATSigner struct{ key *ecdsa.PrivateKey }
func newFakeTATSigner(t *testing.T) *fakeTATSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeTATSigner{key: k}
}
func (f *fakeTATSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestFetchTATWithAssertion_Success(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"access_token":"t-jwt","token_type":"Bearer","expires_in":7200}`}
hc := &http.Client{Transport: rt}
token, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "agent-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "t-jwt" {
t.Errorf("token = %q, want t-jwt", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
form, err := url.ParseQuery(rt.gotBody)
if err != nil {
t.Fatal(err)
}
if form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
t.Errorf("grant_type = %q", form.Get("grant_type"))
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be sent for private_key_jwt")
}
// The assertion's aud must be the bare Open host per the App Authentication
// JWT spec — not the full token endpoint URL.
jwtParts := strings.Split(form.Get("client_assertion"), ".")
if len(jwtParts) != 3 {
t.Fatalf("malformed client_assertion: %q", form.Get("client_assertion"))
}
payload, err := base64.RawURLEncoding.DecodeString(jwtParts[1])
if err != nil {
t.Fatalf("assertion payload not base64url: %v", err)
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
t.Fatal(err)
}
if claims["aud"] != "open.feishu.cn" {
t.Errorf("client_assertion aud = %v, want open.feishu.cn", claims["aud"])
}
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" {
t.Errorf("client_assertion iss/sub = %v/%v, want cli_app", claims["iss"], claims["sub"])
}
if form.Get("client_id") != "cli_app" {
t.Errorf("client_id = %q", form.Get("client_id"))
}
}
func TestFetchTATWithAssertion_NilSigner(t *testing.T) {
hc := &http.Client{Transport: &stubRoundTripper{respCode: 200, respBody: `{}`}}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", nil, "k"); err == nil {
t.Fatal("expected error when signer is nil")
}
}
func TestFetchTATWithAssertion_ServerError(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"error":"invalid_client","error_description":"unknown key"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k"); err == nil {
t.Fatal("expected error for invalid_client response")
}
}
// Deterministic OAuth client rejections must be typed (ConfigError /
// SubtypeInvalidClient) so runProbePKJWT can tell "the key is not bound to this
// app" apart from transport noise.
func TestFetchTATWithAssertion_DeterministicReject_Typed(t *testing.T) {
for _, oauthErr := range []string{"invalid_client", "unauthorized_client", "invalid_grant"} {
rt := &stubRoundTripper{respCode: 401, respBody: `{"error":"` + oauthErr + `","error_description":"bad key"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("%s: expected error", oauthErr)
}
if !errs.IsTyped(err) {
t.Errorf("%s: must be typed, got %T", oauthErr, err)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("%s: want ConfigError/InvalidClient, got %T %v", oauthErr, err, err)
}
}
}
// Unrecognized OAuth errors and non-payload noise stay UNTYPED so the probe
// treats them as upstream noise and stays silent.
func TestFetchTATWithAssertion_AmbiguousError_Untyped(t *testing.T) {
cases := []string{
`{"error":"temporarily_unavailable","error_description":"retry"}`,
`{"code":99999,"msg":"weird"}`,
`not json`,
}
for _, body := range cases {
rt := &stubRoundTripper{respCode: 503, respBody: body}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("body %q: expected error", body)
}
if errs.IsTyped(err) {
t.Errorf("body %q: must be UNTYPED, got typed %T", body, err)
}
}
}

Some files were not shown because too many files have changed in this diff Show More