feat(errs): add structured CLI error contract (#984)

Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.

Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
  embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift

Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.

Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.

At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.

First PR in the feat/error-contract-* series.
This commit is contained in:
evandance
2026-05-26 11:42:33 +08:00
committed by GitHub
parent 877fbe6d47
commit fe72e41fb2
94 changed files with 7703 additions and 1250 deletions

View File

@@ -8,21 +8,14 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
const (
LarkErrBlockByPolicy = 21001 // access denied by access control policy
LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access
needUserAuthorizationMarker = "need_user_authorization"
)
// RefreshTokenRetryable contains error codes that allow one immediate retry.
// All other refresh errors clear the token immediately.
var RefreshTokenRetryable = map[int]bool{
output.LarkErrRefreshServerError: true,
}
// TokenRetryCodes contains error codes that allow retry after token refresh.
var TokenRetryCodes = map[int]bool{
output.LarkErrTokenInvalid: true,
@@ -51,6 +44,7 @@ func IsNeedUserAuthorizationError(err error) bool {
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)
@@ -58,24 +52,7 @@ func IsNeedUserAuthorizationError(err error) bool {
return strings.Contains(err.Error(), needUserAuthorizationMarker)
}
// SecurityPolicyError is returned when a request is blocked by access control policies.
type SecurityPolicyError struct {
Code int
Message string
ChallengeURL string
CLIHint string
Err error
}
// Error returns the error message for SecurityPolicyError.
func (e *SecurityPolicyError) Error() string {
if e.Err != nil {
return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message)
}
// Unwrap returns the underlying error.
func (e *SecurityPolicyError) Unwrap() error {
return e.Err
}
// SecurityPolicyError is preserved as a Go type alias so existing
// errors.As(&SecurityPolicyError{}) consumers (cmd/root.go etc.) keep working.
// The concrete struct lives in errs/types.go.
type SecurityPolicyError = errs.SecurityPolicyError

View File

@@ -12,6 +12,8 @@ import (
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
)
@@ -85,34 +87,56 @@ func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response,
return resp, nil
}
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error response.
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error
// response coming back from a remote server (this transport is installed on
// lark-cli's outbound HTTP client; the bodies it inspects are produced by the
// remote, not by lark-cli itself).
//
// Observed production shape from the MCP gateway — Lark code in the outer
// `error.code` slot, hint under `data.cli_hint`:
//
// {"jsonrpc": "2.0", "id": 1,
// "error": {"code": 21000, "message": "...",
// "data": {"challenge_url": "...", "cli_hint": "..."}}}
//
// The parser also accepts a JSON-RPC-canonical shape (outer `error.code`
// carrying the JSON-RPC status like -32603, Lark code under `error.data.code`,
// hint under `data.hint`) so a future server-side migration to that layout
// would not silently drop policy detection. The Lark code is looked up in the
// central code registry; the hint key is read from `data.hint` first and
// falls back to `data.cli_hint`.
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
// MCP (JSON-RPC) response format:
// {
// "error": {
// "code": 21000,
// "message": "...",
// "data": { "challenge_url": "...", "cli_hint": "..." }
// }
// }
errMap, ok := result["error"].(map[string]interface{})
if !ok {
return nil
}
code := getInt(errMap, "code", 0)
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
dataMap, _ := errMap["data"].(map[string]interface{})
// Try data.code first (shape B); fall back to outer error.code (shape A).
code := 0
if dataMap != nil {
code = getInt(dataMap, "code", 0)
}
if code == 0 {
code = getInt(errMap, "code", 0)
}
meta, ok := errclass.LookupCodeMeta(code)
if !ok || meta.Category != errs.CategoryPolicy {
return nil
}
dataMap, ok := errMap["data"].(map[string]interface{})
if !ok {
if dataMap == nil {
return nil
}
// Clean up backticks and spaces from challenge_url
challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `")
cliHint := getStr(dataMap, "cli_hint")
// Read `hint` first; fall back to `cli_hint` so either spelling surfaces.
cliHint := getStr(dataMap, "hint")
if cliHint == "" {
cliHint = getStr(dataMap, "cli_hint")
}
msg := getStr(errMap, "message")
if challengeUrl != "" || cliHint != "" {
@@ -122,11 +146,15 @@ func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interfa
}
if challengeUrl != "" || cliHint != "" {
return &SecurityPolicyError{
Code: code,
Message: msg,
return &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
CLIHint: cliHint,
}
}
}
@@ -146,8 +174,9 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
}
}
// 2. Check if it's a security policy error
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
// 2. Check if it's a security policy error (consult central code registry)
meta, ok := errclass.LookupCodeMeta(code)
if !ok || meta.Category != errs.CategoryPolicy {
return nil
}
@@ -173,11 +202,15 @@ func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interf
}
if msg != "" || challengeUrl != "" || cliHint != "" {
return &SecurityPolicyError{
Code: code,
Message: msg,
return &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
CLIHint: cliHint,
}
}
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
// This shape is forward-compat for a future server-side migration to the
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
// for the shape observed in production today.
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]interface{}{
"code": -32603, // JSON-RPC internal error
"message": "challenge required",
"data": map[string]interface{}{
"code": 21000, // Lark code for challenge_required
"type": "policy",
"subtype": "challenge_required",
"challenge_url": "https://example.com/challenge",
"hint": "please complete the challenge in your browser",
},
},
}
got := transport.tryHandleMCPResponse(result)
var spErr *errs.SecurityPolicyError
if !errors.As(got, &spErr) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
}
if spErr.Code != 21000 {
t.Errorf("Code = %d, want 21000", spErr.Code)
}
if spErr.Subtype != errs.SubtypeChallengeRequired {
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
}
if spErr.ChallengeURL != "https://example.com/challenge" {
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
}
if spErr.Hint != "please complete the challenge in your browser" {
t.Errorf("Hint = %q", spErr.Hint)
}
}
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
// in production from the MCP gateway: the Lark code sits in the outer
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
// The transport's outer-code fallback path must recognise the policy code and
// surface the typed error with the hint promoted.
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"error": map[string]interface{}{
"code": 21001, // outer slot carries the Lark code
"message": "access denied",
"data": map[string]interface{}{
"challenge_url": "https://example.com/c",
"cli_hint": "contact admin",
},
},
}
got := transport.tryHandleMCPResponse(result)
var spErr *errs.SecurityPolicyError
if !errors.As(got, &spErr) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
}
if spErr.Subtype != errs.SubtypeAccessDenied {
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
}
// `cli_hint` must surface when `hint` is absent.
if spErr.Hint != "contact admin" {
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
}
}
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
// nil (passes through) when the Lark code does not classify as
// CategoryPolicy — keeps regular API errors out of the security-policy path.
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"error": map[string]interface{}{
"code": -32603,
"message": "permission denied",
"data": map[string]interface{}{
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
"type": "authorization",
},
},
}
if err := transport.tryHandleMCPResponse(result); err != nil {
t.Fatalf("expected nil (non-policy code), got %v", err)
}
}

View File

@@ -18,7 +18,9 @@ import (
"time"
"github.com/gofrs/flock"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/vfs"
)
@@ -223,16 +225,21 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
}
code := getInt(data, "code", -1)
if code == LarkErrBlockByPolicy || code == LarkErrBlockByPolicyTryAuth {
meta, metaOK := errclass.LookupCodeMeta(code)
if metaOK && meta.Category == errs.CategoryPolicy {
challengeUrl := getStr(data, "challenge_url")
cliHint := getStr(data, "cli_hint")
msg := getStr(data, "error_description")
return nil, &SecurityPolicyError{
Code: code,
Message: msg,
return nil, &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
CLIHint: cliHint,
}
}
@@ -240,7 +247,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
if (code != -1 && code != 0) || errStr != "" {
// Retryable server error: retry once, then clear token on second failure.
if RefreshTokenRetryable[code] {
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
data, err = callEndpoint()
if err != nil {

View File

@@ -11,6 +11,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
@@ -19,10 +20,31 @@ const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
// actionable API errors for raw `lark-cli api` calls. All other failures
// remain network errors.
//
// Already-classified errors pass through unchanged: any *output.ExitError
// (legacy envelope from output.ErrAuth / output.ErrAPI / output.ErrWithHint)
// and any typed *errs.* error (carries an embedded Problem) keeps its own
// category and exit code. This is what makes the wrap idempotent on the
// auth/credential chain — resolveAccessToken returns output.ErrAuth for
// missing tokens, and that classification must survive the SDK boundary.
//
// Deprecated: legacy *output.ExitError wire shape (api_error + rawAPIJSONHint
// on JSON-decode, network otherwise) for the wrap-from-untyped branch.
// Preserved so SDK Do() callers keep the original envelope until per-domain
// migration to typed errors. New code should route through
// APIClient.CheckResponse (typed *errs.APIError) or construct
// *errs.NetworkError / *errs.InternalError directly.
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
var existing *output.ExitError
if errors.As(err, &existing) {
return err
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
if isJSONDecodeError(err, false) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
@@ -32,6 +54,11 @@ func WrapDoAPIError(err error) error {
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
// into API errors with hints instead of generic parse failures.
//
// Deprecated: legacy *output.ExitError wire shape (api_error + ExitAPI +
// rawAPIJSONHint). The 3-branch behaviour is preserved so existing callers
// of internal/client/response.go keep emitting the same envelope until
// per-domain migration to typed errors.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil

View File

@@ -6,31 +6,15 @@ package client
import (
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
err := WrapDoAPIError(io.EOF)
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
}
if strings.Contains(exitErr.Error(), "invalid JSON response") {
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
}
}
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
@@ -66,3 +50,127 @@ func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
}
}
// TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic pins branch 1 of
// the documented 3-branch behaviour: empty (or whitespace-only) response
// bodies surface as api_error + rawAPIJSONHint, not network. Pages returning
// only "\n" must not be reclassified as transport failures.
func TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic(t *testing.T) {
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("body=%q: expected ExitError, got %T", body, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("body=%q: Code = %d, want %d", body, exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Errorf("body=%q: Detail.Type = %v, want api_error", body, exitErr.Detail)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "empty JSON response") {
t.Errorf("body=%q: Detail.Message = %v, want empty-body diagnostic", body, exitErr.Detail)
}
}
}
// TestWrapJSONResponseParseError_NonJSONErrorIsNetwork pins branch 3:
// a non-JSON-decode error with a non-empty body falls back to ErrNetwork
// (the SDK delivered something but the read itself failed mid-flight).
func TestWrapJSONResponseParseError_NonJSONErrorIsNetwork(t *testing.T) {
raw := errors.New("connection reset by peer")
err := WrapJSONResponseParseError(raw, []byte(`{"code":0,"data":{}}`))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Errorf("Detail.Type = %v, want network", exitErr.Detail)
}
}
// TestWrapDoAPIError_LegacyExitErrorPassesThrough pins the invariant that an
// already-classified *output.ExitError (e.g. output.ErrAuth from
// resolveAccessToken) survives WrapDoAPIError with its category and exit code
// intact. Without this, missing-token errors regress from exit 3/auth to
// exit 4/network at the SDK boundary.
func TestWrapDoAPIError_LegacyExitErrorPassesThrough(t *testing.T) {
cases := []struct {
name string
in error
want int
wantType string
}{
{"auth", output.ErrAuth("no access token available for user"), output.ExitAuth, "auth"},
{"validation", output.ErrValidation("missing flag --foo"), output.ExitValidation, "validation"},
{"api_unknown_code", output.ErrAPI(12345, "unknown lark code", nil), output.ExitAPI, "api_error"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := WrapDoAPIError(tc.in)
if got != tc.in {
t.Fatalf("expected identity passthrough, got %v (orig %v)", got, tc.in)
}
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", got)
}
if exitErr.Code != tc.want {
t.Fatalf("Code = %d, want %d", exitErr.Code, tc.want)
}
if exitErr.Detail == nil || exitErr.Detail.Type != tc.wantType {
t.Fatalf("Detail.Type = %q, want %q (detail=%#v)",
func() string {
if exitErr.Detail == nil {
return "<nil>"
}
return exitErr.Detail.Type
}(),
tc.wantType, exitErr.Detail)
}
})
}
}
// TestWrapDoAPIError_TypedErrsPassesThrough pins that any *errs.* typed error
// (carries an embedded Problem) passes through unchanged. Forward-compat for
// stage-4 credential chain migration that will return *errs.AuthenticationError
// directly instead of legacy output.ErrAuth.
func TestWrapDoAPIError_TypedErrsPassesThrough(t *testing.T) {
cases := []error{
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}},
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}},
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport}},
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError}},
}
for _, in := range cases {
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
got := WrapDoAPIError(in)
if got != in {
t.Fatalf("expected identity passthrough, got %T %v", got, got)
}
})
}
}
// TestWrapDoAPIError_PassthroughBeforeJSONDecode pins that even if a typed/legacy
// error wraps a JSON decode error somewhere in its chain, the outer
// classification takes precedence — we never re-classify an already-typed error
// as a JSON parse error.
func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) {
jsonErr := &json.SyntaxError{Offset: 1}
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr)
got := WrapDoAPIError(authWrappingJSON)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", got)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth)
}
}

View File

@@ -91,12 +91,28 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
//
// 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. In stage 1 that wire shape is still the legacy
// *output.ExitError envelope (network / api_error) — the stage-4 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.* from
// future stages) 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 {
return nil, err
// WrapDoAPIError is idempotent on already-classified errors:
// 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() {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
@@ -107,7 +123,11 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
}
opts = append(opts, extraOpts...)
return c.SDK.Do(ctx, req, opts...)
resp, err := c.SDK.Do(ctx, req, opts...)
if err != nil {
return nil, WrapDoAPIError(err)
}
return resp, nil
}
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
@@ -123,7 +143,10 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
// Resolve auth
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
return nil, err
// See DoSDKRequest comment on the same wrap pattern; the typed
// auth-error pass-through plus untyped fallback applies equally to
// streaming requests.
return nil, WrapDoAPIError(err)
}
// Build URL
@@ -259,14 +282,27 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
}
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. Use DoAPI
// directly when the response may not be JSON (e.g. file downloads).
//
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
// see an *output.ExitError envelope (api_error for malformed JSON, network
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
// or malformed page body would surface to the root handler as a plain-text
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
// framework-boundary migration will flip this wrapper to typed
// *errs.InternalError / *errs.NetworkError.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
return nil, err
}
return ParseJSONResponse(resp)
result, parseErr := ParseJSONResponse(resp)
if parseErr != nil {
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
}
return result, nil
}
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
@@ -410,10 +446,14 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
}
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
// Returns nil if result is not a map, map is nil, or code is 0.
func CheckLarkResponse(result interface{}) error {
// CheckResponse inspects a Lark API response for business-level errors (non-zero code).
//
// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI /
// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so
// existing callers keep emitting the same envelope until per-domain
// migration to typed errors. The identity parameter is reserved for the
// stage-2 typed path; stage-1 ignores it.
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil

View File

@@ -45,12 +45,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
return &credential.TokenResult{Token: "test-token"}, nil
}
type missingTokenResolver struct{}
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
@@ -434,42 +428,118 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
}
}
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request")
return nil, nil
}))
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
// failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{}
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/test",
}, core.AsBot)
if err == nil {
t.Fatal("DoSDKRequest() error = nil, want auth error")
}
var exitErr *output.ExitError
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
}
func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
}
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
// TestDoSDKRequest_AuthFailurePreservesAuthCategory pins the end-to-end
// invariant codex caught the day this PR landed: when resolveAccessToken
// produces output.ErrAuth ("no access token available for <identity>"),
// DoSDKRequest must surface it with the original auth classification —
// not silently downgrade it to a network error via the SDK-failure wrap.
//
// Regression scenario: shortcut path
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
// identity with no cached token. Pre-fix this surfaced as exit 4/type=network
// and routed agents into "check your connection" instead of "log in".
func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "https://example.com/open-apis/test",
}, core.AsBot)
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsUser)
if err == nil {
t.Fatal("DoStream() error = nil, want auth error")
t.Fatal("expected auth error, got nil")
}
var exitErr *output.ExitError
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("DoStream() error = %v, want auth error", err)
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail)
}
}
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
// SDK transport errors get the network classification via WrapDoAPIError.
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
// *url.Error, which the wrap classifier recognises as a transport error.
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
})
ac, _ := newTestAPIClient(t, rt)
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsBot)
if err == nil {
t.Fatal("expected error from broken transport, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
}
}
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the legacy-envelope contract for
// malformed JSON response bodies: WrapJSONResponseParseError emits api_error
// (exit 1) with the rawAPIJSONHint, so the pagination / cmd/api / cmd/service
// callers always see a JSON stderr envelope instead of a bare "Error: ..."
// line. Stage-4 framework-boundary migration will flip this wrapper to typed
// *errs.InternalError; until then this test pins the legacy shape so we do
// not regress envelope coverage.
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{ malformed`)),
}, nil
})
ac, _ := newTestAPIClient(t, rt)
_, err := ac.CallAPI(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users/me",
As: "bot",
})
if err == nil {
t.Fatal("expected JSON parse error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
}
if exitErr.Detail.Hint != rawAPIJSONHint {
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
}
}

View File

@@ -8,25 +8,38 @@ import (
"fmt"
"io"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// PaginationOptions contains pagination control options.
type PaginationOptions struct {
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
PageDelay int // ms, default 200
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
PageDelay int // ms, default 200
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
}
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
jqExpr string, out io.Writer, pagOpts PaginationOptions,
checkErr func(interface{}) error) error {
checkErr func(interface{}, core.Identity) error) error {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.ErrNetwork("API call failed: %v", err)
return err
}
if apiErr := checkErr(result); apiErr != nil {
// Identity resolution honors pagOpts.Identity first, then the request's
// own identity, and only falls back to AsUser when neither caller
// supplied one. Without checking request.As, bot/auto requests would
// always be classified as user identity for checkErr.
identity := pagOpts.Identity
if identity == "" {
identity = request.As
}
if identity == "" || identity == core.AsAuto {
identity = core.AsUser
}
if apiErr := checkErr(result, identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}

View File

@@ -15,6 +15,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
@@ -30,8 +31,13 @@ type ResponseOptions struct {
ErrOut io.Writer // stderr
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
CommandPath string // raw cobra CommandPath() for content safety scanning
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
CheckError func(interface{}) error
// Identity is forwarded to CheckError (default or caller-supplied) so the
// classifier can populate identity-aware fields (e.g. PermissionError.Identity).
// Defaults to core.AsUser when empty.
Identity core.Identity
// CheckError is called on parsed JSON results. Nil defaults to (*APIClient).CheckResponse
// with the Identity field (or AsUser when unset).
CheckError func(result interface{}, identity core.Identity) error
}
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
@@ -40,9 +46,21 @@ type ResponseOptions struct {
// 3. If Content-Type is non-JSON and no --output, auto-save binary to file.
func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
ct := resp.Header.Get("Content-Type")
identity := opts.Identity
if identity == "" {
identity = core.AsUser
}
check := opts.CheckError
if check == nil {
check = CheckLarkResponse
// Stage 1: default check routes through legacy CheckResponse
// (output.ErrAPI / ClassifyLarkError). Stage-2+ migration will
// switch this to errclass.BuildAPIError so PermissionError carries
// MissingScopes / ConsoleURL — at that point a zero-value
// *APIClient still works because BuildAPIError short-circuits on
// empty AppID, gracefully degrading identity-aware fields.
check = func(r interface{}, id core.Identity) error {
return (&APIClient{}).CheckResponse(r, id)
}
}
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
@@ -58,7 +76,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
if err != nil {
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result); apiErr != nil {
if apiErr := check(result, identity); apiErr != nil {
return apiErr
}
// Content safety scanning

View File

@@ -234,37 +234,6 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
}
}
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected error for empty JSON body")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if exitErr.Detail.Message != "API returned an empty JSON response body" {
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
@@ -424,17 +393,3 @@ func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
t.Errorf("saved_path should be absolute, got %q", savedPath)
}
}
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
resp := newApiRespWithStatus(403, body, 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.Fatal("expected error for 403 JSON with non-zero code")
}
if !strings.Contains(err.Error(), "99991400") {
t.Errorf("expected lark error code in message, got: %s", err.Error())
}
}

View File

@@ -130,6 +130,13 @@ func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
// 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 &output.ExitError{

View File

@@ -19,6 +19,13 @@ import (
// 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 &output.ExitError{
Code: output.ExitConfirmationRequired,

View File

@@ -236,7 +236,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
app := raw.CurrentAppConfig(profileOverride)
if app == nil {
return nil, &ConfigError{
Code: 2,
Code: 3,
Type: "config",
Message: fmt.Sprintf("profile %q not found", profileOverride),
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
@@ -244,20 +244,19 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, &ConfigError{Code: 2, Type: "config",
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 {
// If the error comes from the keychain, it will already be wrapped as an ExitError.
// For other errors (e.g. file read errors, unknown sources), wrap them as ConfigError.
// 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: 2, Type: "config", Message: err.Error()}
return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
}
cfg := &CliConfig{
ProfileName: app.ProfileName(),

View File

@@ -8,7 +8,7 @@ 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: 2=validation, 3=auth
Code int // exit code: 3 (config errors share the auth exit code)
Type string // "config" or "auth"
Message string
Hint string

View File

@@ -31,7 +31,7 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
// keeps it on the standard structured-envelope path at the root
// command's error sink.
return nil, &ConfigError{
Code: 2,
Code: 3,
Type: "config",
Message: fmt.Sprintf("failed to load config: %v", err),
}
@@ -71,14 +71,14 @@ func NotConfiguredError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return &ConfigError{
Code: 2,
Code: 3,
Type: "config",
Message: "not configured",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 2,
Code: 3,
Type: ws.Display(),
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
Hint: agentBindHint,
@@ -105,14 +105,14 @@ func NoActiveProfileError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return &ConfigError{
Code: 2,
Code: 3,
Type: "config",
Message: "no active profile",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 2,
Code: 3,
Type: ws.Display(),
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
Hint: agentBindHint,

View File

@@ -0,0 +1,284 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
}
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
// Returns nil when resp is nil or resp["code"] is 0.
//
// Routing by Category:
//
// Authorization → *errs.PermissionError (with MissingScopes / Identity / ConsoleURL)
// Authentication → *errs.AuthenticationError
// Config → *errs.ConfigError
// Policy → *errs.SecurityPolicyError
// Validation → *errs.ValidationError
// Network → *errs.NetworkError
// Internal → *errs.InternalError
// Confirmation → *errs.ConfirmationRequiredError
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
//
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
// CategoryAPI + SubtypeUnknown.
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
if resp == nil {
return nil
}
code := intFromAny(resp["code"])
if code == 0 {
return nil
}
msg, _ := resp["msg"].(string)
if msg == "" {
// Upstream omitted or sent non-string msg. Keep Problem.Message non-empty
// so the typed wire envelope still carries a human-readable signal.
msg = fmt.Sprintf("API error: [%d]", code)
}
// Lark API responses sometimes carry log_id at the top level
// ({"code":..., "log_id":"..."}) and sometimes nested under "error"
// ({"code":..., "error":{"log_id":"..."}}). Prefer top level and fall
// back to the nested location so log_id always surfaces on the typed
// envelope.
logID, _ := resp["log_id"].(string)
if logID == "" {
if errBlock, ok := resp["error"].(map[string]any); ok {
if nested, ok := errBlock["log_id"].(string); ok {
logID = nested
}
}
}
meta, ok := LookupCodeMeta(code)
if !ok {
meta = CodeMeta{Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}
}
base := errs.Problem{
Category: meta.Category,
Subtype: meta.Subtype,
Code: code,
Message: msg,
LogID: logID,
Retryable: meta.Retryable,
}
switch meta.Category {
case errs.CategoryAuthorization:
return buildPermissionError(base, resp, cc)
case errs.CategoryAuthentication:
return &errs.AuthenticationError{Problem: base}
case errs.CategoryConfig:
return &errs.ConfigError{Problem: base}
case errs.CategoryPolicy:
return buildSecurityPolicyError(base, resp)
case errs.CategoryValidation:
return &errs.ValidationError{Problem: base}
case errs.CategoryNetwork:
return &errs.NetworkError{Problem: base}
case errs.CategoryInternal:
return &errs.InternalError{Problem: base}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{Problem: base}
default:
return &errs.APIError{Problem: base, Detail: resp}
}
}
// buildSecurityPolicyError extracts challenge_url and the hint from a Lark API
// response's data block, so the typed SecurityPolicyError carries the same
// browser-challenge information that internal/auth/transport.go surfaces at
// the HTTP layer.
//
// Data shapes accepted (whichever the upstream sends):
//
// {"code": 21000, "msg": "...", "data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}
// {"code": 21000, "error": {"data": {"challenge_url": "...", "hint"|"cli_hint": "..."}}}
//
// challenge_url is dropped (set to "") if it is not an https:// URL — same
// validation policy as internal/auth/transport.go.isValidChallengeURL.
// Hint is read from `data.hint` first and falls back to `data.cli_hint` so
// either spelling surfaces, matching the transport layer.
func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.SecurityPolicyError {
dataMap, _ := resp["data"].(map[string]any)
if dataMap == nil {
if errBlock, ok := resp["error"].(map[string]any); ok {
dataMap, _ = errBlock["data"].(map[string]any)
}
}
if dataMap == nil {
return &errs.SecurityPolicyError{Problem: p}
}
challengeURL := strings.Trim(stringFromAny(dataMap["challenge_url"]), " `")
if challengeURL != "" && !isHTTPSURL(challengeURL) {
challengeURL = ""
}
hint := stringFromAny(dataMap["hint"])
if hint == "" {
hint = stringFromAny(dataMap["cli_hint"])
}
if hint != "" {
p.Hint = hint
}
return &errs.SecurityPolicyError{
Problem: p,
ChallengeURL: challengeURL,
}
}
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
// the two will collapse when the auth transport adopts BuildAPIError in stage 4.
func isHTTPSURL(rawURL string) bool {
if rawURL == "" {
return false
}
u, err := url.Parse(rawURL)
if err != nil {
return false
}
return u.Scheme == "https"
}
// stringFromAny coerces a map value to string when it is a string, returning "" otherwise.
func stringFromAny(v any) string {
s, _ := v.(string)
return s
}
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
missing := extractMissingScopes(resp)
identity := cc.Identity
if identity == "" {
identity = "user"
}
p.Hint = PermissionHint(missing, identity, p.Subtype)
return &errs.PermissionError{
Problem: p,
MissingScopes: missing,
Identity: identity,
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
}
}
// PermissionHint returns an actionable next-step string for a permission
// error. User identity with a missing user-scope is recovered by re-running
// `auth login --scope ...`; bot identity or app-level scope errors are
// recovered by enabling scopes in the open-platform console. The subtype
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
// where re-authentication will not help regardless of the caller identity.
//
// Exported so direct construction sites (cmd/service/service.go's
// checkServiceScopes) can produce hints that match the dispatcher path
// byte-for-byte instead of hand-rolling divergent strings.
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
// app_scope_not_enabled means the scope has not been granted at the
// app (developer console) level — re-authenticating cannot fix it,
// so route every caller identity to the console hint.
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
if len(missing) == 0 {
if useConsole {
return "check the app's scope grant in the Lark open platform console"
}
return "ensure the calling identity has been granted the required scopes"
}
scopes := strings.Join(missing, " ")
if useConsole {
return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes)
}
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes)
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
// Returns nil when the structure is absent.
func extractMissingScopes(resp map[string]any) []string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return nil
}
raw, ok := errBlock["permission_violations"].([]any)
if !ok || len(raw) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, v := range raw {
m, ok := v.(map[string]any)
if !ok {
continue
}
s, _ := m["subject"].(string)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
// scopes list returns the bare /auth landing page; scopes are joined with
// commas in the `q` query parameter so the console can pre-select them.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
// PathEscape on appID — it sits in the URL path. QueryEscape on the
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
}
func intFromAny(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case json.Number:
i, err := n.Int64()
if err == nil {
return int(i)
}
f, err := n.Float64()
if err == nil {
return int(f)
}
}
return 0
}

View File

@@ -0,0 +1,747 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass_test
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
)
// missingScopeResp builds a minimal Lark missing-scope response with one
// violation. Shared across the envelope-shape and brand-switch tests.
func missingScopeResp(scope string) map[string]any {
return map[string]any{
"code": 99991679,
"msg": "scope missing",
"error": map[string]any{
"permission_violations": []any{
map[string]any{"subject": scope},
},
},
}
}
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
t.Errorf("nil resp should return nil error, got %v", got)
}
if got := errclass.BuildAPIError(map[string]any{"code": 0, "msg": "ok"}, errclass.ClassifyContext{}); got != nil {
t.Errorf("code=0 should return nil error, got %v", got)
}
// json.Number 0 path (real-world SDK decodes with UseNumber)
resp := map[string]any{"code": json.Number("0"), "msg": "ok"}
if got := errclass.BuildAPIError(resp, errclass.ClassifyContext{}); got != nil {
t.Errorf("json.Number(0) should return nil error, got %v", got)
}
}
// matchesTypedError reports whether err is the typed-error variant identified by
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
// through to *APIError) fails loudly instead of passing on Category alone.
func matchesTypedError(err error, wantTyped string) bool {
switch wantTyped {
case "PermissionError":
var x *errs.PermissionError
return errors.As(err, &x)
case "AuthenticationError":
var x *errs.AuthenticationError
return errors.As(err, &x)
case "ValidationError":
var x *errs.ValidationError
return errors.As(err, &x)
case "NetworkError":
var x *errs.NetworkError
return errors.As(err, &x)
case "ConfigError":
var x *errs.ConfigError
return errors.As(err, &x)
case "InternalError":
var x *errs.InternalError
return errors.As(err, &x)
case "ConfirmationRequiredError":
var x *errs.ConfirmationRequiredError
return errors.As(err, &x)
case "SecurityPolicyError":
var x *errs.SecurityPolicyError
return errors.As(err, &x)
case "APIError":
// APIError is the default fallback; use a direct type assertion to avoid
// matching against typed subclasses that also satisfy IsAPI.
_, ok := err.(*errs.APIError)
return ok
}
return false
}
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
cases := []struct {
name string
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantExit int
wantTyped string
}{
{"99991672 app_missing_scope", 99991672, errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, 3, "PermissionError"},
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.Subtype("task_permission_denied"), 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
{"unknown code 999999", 999999, errs.CategoryAPI, errs.SubtypeUnknown, 1, "APIError"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := map[string]any{"code": tc.code, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
if err == nil {
t.Fatalf("expected error for code %d, got nil", tc.code)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for code %d (err = %T)", tc.code, err)
}
if p.Category != tc.wantCat {
t.Errorf("Category = %q, want %q", p.Category, tc.wantCat)
}
if p.Subtype != tc.wantSubtype {
t.Errorf("Subtype = %q, want %q", p.Subtype, tc.wantSubtype)
}
if got := output.ExitCodeOf(err); got != tc.wantExit {
t.Errorf("ExitCodeOf = %d, want %d (typed = %s)", got, tc.wantExit, tc.wantTyped)
}
if !matchesTypedError(err, tc.wantTyped) {
t.Errorf("typed-error mismatch: got %T, want %s", err, tc.wantTyped)
}
})
}
}
// TestBuildAPIError_ValidationRoutesToValidationError pins that code 1470400
// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not
// the default *errs.APIError. The dispatcher must read codeMeta.Category and
// route accordingly so the embedded Problem.Category matches the wire type.
func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) {
resp := map[string]any{"code": 1470400, "msg": "bad params"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected error for code 1470400")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if _, isAPI := err.(*errs.APIError); isAPI {
t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
}
func TestPermissionErrorEnvelopeShape(t *testing.T) {
resp := map[string]any{
"code": 99991679,
"msg": "missing scope",
"log_id": "lg-1",
"error": map[string]any{
"permission_violations": []any{
map[string]any{"subject": "docx:document"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
var buf bytes.Buffer
ok := output.WriteTypedErrorEnvelope(&buf, err, "user")
if !ok {
t.Fatal("WriteTypedErrorEnvelope returned false for typed error")
}
out := buf.String()
// positive assertions
for _, want := range []string{
`"type": "authorization"`,
`"subtype": "missing_scope"`,
`"code": 99991679`,
`"missing_scopes":`,
`"docx:document"`,
`"console_url":`,
`open.feishu.cn/app/cli_a123/auth`,
`"identity": "user"`,
`"log_id": "lg-1"`,
} {
if !strings.Contains(out, want) {
t.Errorf("envelope missing %q\nfull: %s", want, out)
}
}
// negative assertions on the wire format
for _, mustNot := range []string{
`"component"`,
`"doc_url"`,
`"retryable":`, // Retryable defaults false, omitempty → key absent
} {
if strings.Contains(out, mustNot) {
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
}
}
}
func TestRetryableEnvelope_TrueOnly(t *testing.T) {
// Test 1: Retryable:true → key present
apiErr := &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Message: "x", Retryable: true,
}}
var buf bytes.Buffer
output.WriteTypedErrorEnvelope(&buf, apiErr, "user")
if !strings.Contains(buf.String(), `"retryable": true`) {
t.Errorf("Retryable:true should emit key; got: %s", buf.String())
}
// Test 2: Retryable:false → key absent
buf.Reset()
apiErr2 := &errs.APIError{Problem: errs.Problem{
Category: errs.CategoryAPI, Message: "x", Retryable: false,
}}
if ok := output.WriteTypedErrorEnvelope(&buf, apiErr2, "user"); !ok {
t.Fatal("WriteTypedErrorEnvelope returned false for typed error — emission failed silently")
}
if strings.Contains(buf.String(), `"retryable"`) {
t.Errorf("Retryable:false should omit key; got: %s", buf.String())
}
}
func TestConsoleURL_FeishuBrand(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
}
}
func TestConsoleURL_LarkBrand(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
}
}
func TestConsoleURL_EmptyAppID(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
pe := err.(*errs.PermissionError)
if pe.ConsoleURL != "" {
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
}
}
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
// scope values so a hostile value cannot break out of the URL framing
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
tests := []struct {
name string
appID string
scopes []string
wantInURL []string // substrings that MUST appear
denyInURL []string // substrings that MUST NOT appear
}{
{
name: "ampersand in scope smuggles extra param",
appID: "cli_good",
scopes: []string{"scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
},
{
name: "slash in appID escapes path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes)
for _, want := range tt.wantInURL {
if !strings.Contains(got, want) {
t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got)
}
}
for _, deny := range tt.denyInURL {
if strings.Contains(got, deny) {
t.Errorf("ConsoleURL contains unescaped dangerous substring\n deny: %s\n got: %s", deny, got)
}
}
})
}
}
func TestPermissionError_DefaultIdentity(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
pe := err.(*errs.PermissionError)
if pe.Identity != "user" {
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
}
}
func TestPermissionError_NoViolations(t *testing.T) {
// permission error without a permission_violations array → MissingScopes nil,
// ConsoleURL falls back to the no-scope form.
resp := map[string]any{"code": 99991679, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe := err.(*errs.PermissionError)
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
}
}
func TestExtractMissingScopes_Dedup(t *testing.T) {
resp := map[string]any{
"code": 99991679,
"msg": "x",
"error": map[string]any{
"permission_violations": []any{
map[string]any{"subject": "docx:document"},
map[string]any{"subject": "docx:document"}, // dup
map[string]any{"subject": ""}, // ignored
map[string]any{"subject": "im:message"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe := err.(*errs.PermissionError)
if got, want := len(pe.MissingScopes), 2; got != want {
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
}
}
// TestServiceShortcutEnvelopeConverge guards that the wire envelope is
// identical whether produced via the dispatcher (BuildAPIError — the normal
// service / shortcut path) or constructed directly at the call site (the
// cmd/service permission path).
//
// cmd/service/service.go's checkServiceScopes builds PermissionError using the
// exported PermissionHint and ConsoleURL helpers — the same helpers
// BuildAPIError uses. The hand-constructed branch below intentionally mirrors
// service.go line-by-line so a future drift on either side (e.g. a new
// extension field on PermissionError that only BuildAPIError populates) fails
// loudly here. The remaining limitation is that this test invokes the helpers
// directly rather than driving checkServiceScopes (which requires a credential
// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight
// mock harness lands.
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
const (
brand = "feishu"
appID = "cli_a123"
identity = "user"
)
missing := []string{"docx:document"}
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
resp := missingScopeResp(missing[0])
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
dispatcherPE, ok := dispatcherErr.(*errs.PermissionError)
if !ok {
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
}
// Path B: direct construction — exactly mirrors cmd/service/service.go's
// checkServiceScopes (same helpers, same field-fill order). Code
// and Message are copied from Path A so the byte-comparison below isolates
// the contract under test (Hint + Identity + ConsoleURL convergence).
directErr := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypeMissingScope,
Code: dispatcherPE.Code,
Message: dispatcherPE.Message,
Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope),
},
MissingScopes: missing,
Identity: identity,
ConsoleURL: errclass.ConsoleURL(brand, appID, missing),
}
var bufA, bufB bytes.Buffer
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
t.Fatal("dispatcher path failed to emit typed envelope")
}
if ok := output.WriteTypedErrorEnvelope(&bufB, directErr, identity); !ok {
t.Fatal("direct path failed to emit typed envelope")
}
if bufA.String() != bufB.String() {
t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String())
}
}
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
// Mirrors what the cmd/service direct-construction path produces.
pe := &errs.PermissionError{
Problem: errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypeMissingScope,
Message: "missing required scope(s): docx:document",
},
MissingScopes: []string{"docx:document"},
Identity: "user",
}
if got := output.ExitCodeOf(pe); got != 3 {
t.Errorf("ExitCodeOf = %d, want 3", got)
}
if !errs.IsPermission(pe) {
t.Error("expected IsPermission(pe) == true")
}
}
func TestWriteTypedEnvelope_UntypedReturnsFalse(t *testing.T) {
var buf bytes.Buffer
if output.WriteTypedErrorEnvelope(&buf, errors.New("plain"), "user") {
t.Error("expected WriteTypedErrorEnvelope to return false for untyped error")
}
if buf.Len() > 0 {
t.Errorf("expected no output for untyped error, got: %s", buf.String())
}
}
func TestBuildAPIError_LogIDNestedInError(t *testing.T) {
// Some Lark API responses carry log_id nested under "error" rather than
// at the top level. BuildAPIError must surface either location.
resp := map[string]any{
"code": 99991679,
"msg": "missing scope",
"error": map[string]any{
"log_id": "lg-nested-123",
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.LogID != "lg-nested-123" {
t.Errorf("LogID = %q, want lg-nested-123", p.LogID)
}
}
func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
resp := map[string]any{
"code": 99991679,
"msg": "missing scope",
"log_id": "lg-top-456",
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.LogID != "lg-top-456" {
t.Errorf("LogID = %q, want lg-top-456", p.LogID)
}
}
func TestBuildPermissionHint_UserWithScopes(t *testing.T) {
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope)
if !strings.Contains(got, "lark-cli auth login") {
t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got)
}
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
t.Errorf("user hint should include missing scopes; got %q", got)
}
}
func TestBuildPermissionHint_BotWithScopes(t *testing.T) {
got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope)
if !strings.Contains(got, "open platform console") {
t.Errorf("bot hint should mention the open-platform console; got %q", got)
}
if strings.Contains(got, "auth login") {
t.Errorf("bot hint must not suggest re-running `auth login`; got %q", got)
}
}
func TestBuildPermissionHint_NoScopes(t *testing.T) {
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") {
t.Errorf("user no-scope hint missing fallback wording; got %q", got)
}
if got := errclass.PermissionHint(nil, "bot", errs.SubtypeMissingScope); !strings.Contains(got, "open platform console") {
t.Errorf("bot no-scope hint should still point at the console; got %q", got)
}
}
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// 99991672 / app_scope_not_enabled means the scope has not been granted
// at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever.
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied)
if !strings.Contains(got, "open platform console") {
t.Errorf("identity=%q: hint should point to console; got %q", identity, got)
}
if strings.Contains(got, "auth login") {
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
}
}
}
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
// Regression: code 99991672 with user identity previously emitted
// `lark-cli auth login --scope ...` which sends agents into a re-auth
// loop because the missing scope is not yet enabled at the app level.
resp := map[string]any{
"code": 99991672,
"msg": "app scope not enabled",
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
}
if !strings.Contains(p.Hint, "open platform console") {
t.Errorf("Hint should route to console; got %q", p.Hint)
}
if strings.Contains(p.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
}
}
func TestPermissionError_HintPopulated(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok, err = %T", err)
}
if p.Hint == "" {
t.Error("PermissionError.Hint should be populated by BuildAPIError")
}
if !strings.Contains(p.Hint, "docx:document") {
t.Errorf("Hint should reference missing scope; got %q", p.Hint)
}
}
func TestBuildAPIError_JSONNumberCode(t *testing.T) {
// SDK parses with json.Number; verify intFromAny handles it.
resp := map[string]any{"code": json.Number("99991679"), "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
if err == nil {
t.Fatal("expected error for json.Number-encoded code")
}
if _, ok := err.(*errs.PermissionError); !ok {
t.Errorf("expected *errs.PermissionError, got %T", err)
}
}
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
// passing through BuildAPIError keep the browser-challenge URL and hint —
// agents need challenge_url to drive the user through MFA / device-trust
// flows. Without extraction, the typed envelope is degenerate vs. what the
// internal/auth/transport.go HTTP-layer interceptor already produces.
func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
resp := map[string]any{
"code": 21000,
"msg": "challenge required",
"data": map[string]any{
"challenge_url": "https://passport.feishu.cn/challenge/xyz",
"hint": "complete MFA in the browser, then retry",
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
}
if spe.Hint != "complete MFA in the browser, then retry" {
t.Errorf("Hint = %q, want MFA hint", spe.Hint)
}
}
// TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint pins that responses
// using data.cli_hint still surface via Hint when data.hint is absent.
func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
resp := map[string]any{
"code": 21001,
"msg": "access denied",
"data": map[string]any{
"cli_hint": "ask your admin for elevated approval",
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.Hint != "ask your admin for elevated approval" {
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
}
}
// TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge pins that an
// untrusted challenge_url (non-https) is dropped — same policy as
// internal/auth/transport.go isValidChallengeURL.
func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
cases := []string{
"http://attacker.example.com/challenge",
"javascript:alert(1)",
"ftp://example.com/challenge",
"not a url at all",
}
for _, bad := range cases {
t.Run(bad, func(t *testing.T) {
resp := map[string]any{
"code": 21000,
"msg": "challenge required",
"data": map[string]any{"challenge_url": bad, "hint": "h"},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
}
})
}
}
// TestBuildAPIError_SecurityPolicyNoData pins the no-data case — typed
// envelope still routes correctly with empty extension fields when the
// upstream response carries only code+msg.
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
resp := map[string]any{"code": 21000, "msg": "challenge required"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
}
if spe.Message != "challenge required" {
t.Errorf("Message = %q, want challenge required", spe.Message)
}
}
// TestBuildAPIError_SecurityPolicyMalformedData pins that malformed `data`
// blocks (wrong type, wrong shape, non-string fields) degrade gracefully —
// extension fields stay empty, no panic. Server-side bugs or transitional
// API shapes must never crash the CLI dispatcher.
func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
cases := []struct {
name string
resp map[string]any
}{
{"data is string not map", map[string]any{"code": 21000, "msg": "x", "data": "oops"}},
{"data is array not map", map[string]any{"code": 21000, "msg": "x", "data": []any{1, 2}}},
{"data is nil", map[string]any{"code": 21000, "msg": "x", "data": nil}},
{"challenge_url is int", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": 123}}},
{"challenge_url is nil", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": nil}}},
{"hint is array", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"hint": []any{"a"}}}},
{"error.data is wrong type", map[string]any{"code": 21000, "msg": "x", "error": map[string]any{"data": "oops"}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("BuildAPIError panicked on malformed data: %v", r)
}
}()
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
}
if spe.ChallengeURL != "" {
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
}
})
}
}
// TestBuildAPIError_SecurityPolicyErrorDataShape pins extraction from the
// {"error": {"data": {...}}} envelope variant — same lookup paths the
// transport-layer interceptor uses on inbound responses.
func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
resp := map[string]any{
"code": 21000,
"msg": "challenge required",
"error": map[string]any{
"data": map[string]any{
"challenge_url": "https://passport.feishu.cn/c/abc",
"hint": "wrapped variant",
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
spe, ok := err.(*errs.SecurityPolicyError)
if !ok {
t.Fatalf("expected *SecurityPolicyError, got %T", err)
}
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
}
if spe.Hint != "wrapped variant" {
t.Errorf("Hint = %q, want wrapped variant", spe.Hint)
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"github.com/larksuite/cli/errs"
)
// CodeMeta is the classification metadata attached to a Lark numeric code.
// It does NOT carry Message or Hint — those are derived at the dispatcher
// (see BuildAPIError).
type CodeMeta struct {
Category errs.Category
Subtype errs.Subtype
Retryable bool
}
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
// policy/config codes shared across services) live here; service-specific
// sub-tables (e.g. task) live in dedicated files like codemeta_task.go and
// merge into this map via init().
//
// Go language guarantees package-level vars initialize before init() functions,
// so sub-tables registering via init() can always assume codeMeta is non-nil.
var codeMeta = map[int]CodeMeta{
// CategoryAuthentication
99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing
99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-)
99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish)
99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid
99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired
20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format
20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired
20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked
20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used
20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error
// CategoryAuthorization
99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false},
99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false},
99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope
230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app
99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable
99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant
// CategoryAPI
99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true},
1061045: {errs.CategoryAPI, errs.SubtypeConflict, true},
131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff
1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false},
1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false},
1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable
1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false},
// CategoryConfig
99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect
// CategoryPolicy
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
}
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
// the caller (BuildAPIError) is responsible for falling back to
// CategoryAPI/SubtypeUnknown.
func LookupCodeMeta(code int) (CodeMeta, bool) {
m, ok := codeMeta[code]
return m, ok
}
// mergeCodeMeta is invoked by sub-table init() functions to merge service-specific
// codes into the central registry. Panics on duplicate code so a misregistration
// fails fast at startup rather than producing silently-inconsistent classification.
func mergeCodeMeta(src map[int]CodeMeta, owner string) {
for code, meta := range src {
if existing, dup := codeMeta[code]; dup {
panic(fmt.Sprintf("codeMeta dup: code %d already mapped %+v; %s wants %+v",
code, existing, owner, meta))
}
codeMeta[code] = meta
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// taskCodeMeta holds the task-service-specific Lark code classifications.
// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task
// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this
// map via mergeCodeMeta + LookupCodeMeta.
var taskCodeMeta = map[int]CodeMeta{
1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false},
1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied
1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false},
1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true},
1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true},
1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false},
1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false},
1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false},
1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false},
}
func init() { mergeCodeMeta(taskCodeMeta, "task") }

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMeta_MissingScope(t *testing.T) {
got, ok := LookupCodeMeta(99991679)
if !ok {
t.Fatalf("LookupCodeMeta(99991679) ok=false, want true")
}
want := CodeMeta{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Retryable: false}
if got != want {
t.Fatalf("LookupCodeMeta(99991679) = %+v, want %+v", got, want)
}
}
func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
got, ok := LookupCodeMeta(1470403)
if !ok {
t.Fatalf("LookupCodeMeta(1470403) ok=false, want true (task sub-table init merge)")
}
if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
}
if got.Subtype != errs.Subtype("task_permission_denied") {
t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied")
}
if got.Retryable {
t.Errorf("Retryable = true, want false")
}
}
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
got, ok := LookupCodeMeta(20050)
if !ok {
t.Fatalf("LookupCodeMeta(20050) ok=false, want true")
}
if !got.Retryable {
t.Errorf("LookupCodeMeta(20050).Retryable = false, want true (sole retryable refresh code)")
}
if got.Category != errs.CategoryAuthentication {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthentication)
}
}
func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) {
got, ok := LookupCodeMeta(99991400)
if !ok {
t.Fatalf("LookupCodeMeta(99991400) ok=false, want true")
}
if !got.Retryable {
t.Errorf("LookupCodeMeta(99991400).Retryable = false, want true (rate_limit retryable)")
}
if got.Subtype != errs.SubtypeRateLimit {
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeRateLimit)
}
}
func TestLookupCodeMeta_Unknown(t *testing.T) {
_, ok := LookupCodeMeta(999999)
if ok {
t.Fatalf("LookupCodeMeta(999999) ok=true, want false for unknown code")
}
}
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
got, ok := LookupCodeMeta(21000)
if !ok {
t.Fatalf("LookupCodeMeta(21000) ok=false, want true")
}
if got.Category != errs.CategoryPolicy {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryPolicy)
}
if got.Subtype != errs.Subtype("challenge_required") {
t.Errorf("Subtype = %q, want %q", got.Subtype, "challenge_required")
}
}
func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
defer func() {
r := recover()
if r == nil {
t.Fatalf("mergeCodeMeta with duplicate code did not panic")
}
msg, ok := r.(string)
if !ok {
t.Fatalf("panic value is not a string: %T (%v)", r, r)
}
for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} {
if !strings.Contains(msg, needle) {
t.Errorf("panic message %q missing substring %q", msg, needle)
}
}
}()
mergeCodeMeta(map[int]CodeMeta{
1470403: {Category: errs.CategoryAPI, Subtype: errs.Subtype("intruder")},
}, "test")
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package errcompat bridges the legacy *core.ConfigError shape into the
// canonical typed errors taxonomy in errs/. It is a thin boundary helper —
// placed in its own package so it can import both core (for the legacy
// type) and errs (for the typed targets) without creating an import cycle
// with internal/errclass, which intentionally avoids depending on
// internal/core.
package errcompat
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// PromoteConfigError is the stage-2 boundary helper that will convert a
// *core.ConfigError into the matching typed errs.* error. In stage 1 it
// is a passthrough — the dispatcher continues to render *core.ConfigError
// via the legacy envelope path (cmd/root.go asExitError) so the wire
// shape stays identical to pre-PR. Per-domain typed migration in stage 2+
// will fill in the actual promotion logic alongside its corresponding
// wire-change announcement.
func PromoteConfigError(cfgErr *core.ConfigError) error {
if cfgErr == nil {
return nil
}
return cfgErr
}
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
var _ = errs.CategoryConfig

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errcompat_test
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errcompat"
)
// TestPromoteConfigError_Stage1Passthrough pins the stage-1 passthrough
// behaviour: every input *core.ConfigError flows out unchanged so the
// dispatcher's legacy envelope path emits the same wire shape as pre-PR.
// Per-domain typed migration will replace this in stage 2+.
func TestPromoteConfigError_Stage1Passthrough(t *testing.T) {
for _, cfgType := range []string{"config", "auth", "openclaw", ""} {
t.Run(cfgType, func(t *testing.T) {
src := &core.ConfigError{Code: 3, Type: cfgType, Message: "msg", Hint: "hint"}
out := errcompat.PromoteConfigError(src)
var got *core.ConfigError
if !errors.As(out, &got) || got != src {
t.Fatalf("Type=%q: expected passthrough of original *core.ConfigError, got %T (%v)", cfgType, out, out)
}
})
}
}
// TestPromoteConfigError_NilInputReturnsNil pins that PromoteConfigError on a
// nil input returns nil rather than panicking on the (cfgErr.Type) access.
func TestPromoteConfigError_NilInputReturnsNil(t *testing.T) {
if got := errcompat.PromoteConfigError(nil); got != nil {
t.Errorf("PromoteConfigError(nil) = %v, want nil", got)
}
}

View File

@@ -199,6 +199,13 @@ func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invoca
// *output.ExitError so cmd/root.go's envelope writer emits the right
// JSON structure (type="hook"). Non-AbortError values pass through
// unchanged.
//
// Deprecated: wrapAbortError converts to a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — hook abort signals should move to a typed
// *errs.XxxError (typed hook error is tracked for the hook framework
// 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 wrapAbortError(err error) error {
if err == nil {
return nil

View File

@@ -14,6 +14,14 @@ type Envelope struct {
}
// ErrorEnvelope is the standard error response wrapper.
//
// Deprecated: ErrorEnvelope belongs to the legacy *output.ExitError surface
// that predates the typed error contract introduced by errs/. New code MUST
// NOT use it — the typed envelope shape is owned by
// internal/output.WriteTypedErrorEnvelope which marshals typed errs.* errors
// directly via JSON reflection (no wrapper struct needed). This struct is
// retained only while existing *ExitError call sites are migrated; it will
// be removed once they have moved to the typed surface.
type ErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
@@ -23,6 +31,13 @@ type ErrorEnvelope struct {
}
// ErrDetail describes a structured error.
//
// Deprecated: ErrDetail belongs to the legacy *output.ExitError surface that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — typed errs.* structs embed errs.Problem and own their wire shape
// via JSON tags (Category, Subtype, Hint, etc. promote to the top level).
// This struct is retained only while existing *ExitError call sites are
// migrated; it will be removed once they have moved to the typed surface.
type ErrDetail struct {
Type string `json:"type"`
Code int `json:"code,omitempty"`
@@ -37,6 +52,14 @@ type ErrDetail struct {
// confirmation_required errors. Level is one of "read" | "write" |
// "high-risk-write". Action identifies the command for the agent (e.g.
// "mail +send", "drive.files.delete").
//
// Deprecated: RiskDetail is reachable only via *output.ExitError.Detail.Risk,
// part of the legacy envelope surface that predates the typed error contract
// introduced by errs/. New code MUST NOT use it — confirmation-required
// signals belong on *errs.ConfirmationRequiredError (its own typed extension
// fields can carry agent-protocol metadata directly). This struct is
// retained only while existing *ExitError call sites are migrated; it will
// be removed once they have moved to the typed surface.
type RiskDetail struct {
Level string `json:"level"`
Action string `json:"action"`

View File

@@ -9,16 +9,26 @@ import (
"errors"
"fmt"
"io"
"github.com/larksuite/cli/errs"
)
// ExitError is a structured error that carries an exit code and optional detail.
// It is propagated up the call chain and handled by main.go to produce
// a JSON error envelope on stderr and the correct exit code.
//
// Deprecated: *output.ExitError is the legacy error type that predates the
// typed error contract introduced by errs/. New code MUST NOT instantiate it
// — return a typed *errs.XxxError (see errs/ for the available categories:
// *AuthenticationError / *PermissionError / *ValidationError / *NetworkError /
// *APIError / *InternalError / etc.). This type is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
type ExitError struct {
Code int
Detail *ErrDetail
Err error
Raw bool // when true, skip enrichment (e.g. enrichPermissionError) and preserve original error
Raw bool // when true, the dispatcher skips enrichment (e.g. enrichPermissionError) and preserves the original error detail
}
func (e *ExitError) Error() string {
@@ -35,7 +45,31 @@ func (e *ExitError) Unwrap() error {
return e.Err
}
// MarkRaw sets Raw=true on an ExitError so that the dispatcher skips
// enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and
// preserves the original API error detail. Returns the original error
// unchanged if it is not (or does not wrap) an ExitError.
//
// Used by `cmd/api` and other "passthrough" call sites where the caller
// explicitly wants the raw Lark API detail (log_id, troubleshooter, etc.)
// on the wire rather than the enriched message/hint variant.
func MarkRaw(err error) error {
var exitErr *ExitError
if errors.As(err, &exitErr) {
exitErr.Raw = true
}
return err
}
// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w.
//
// Deprecated: WriteErrorEnvelope is the legacy envelope writer paired with
// *output.ExitError, which predates the typed error contract introduced by
// errs/. New code MUST NOT call this directly — return a typed *errs.XxxError
// from the command, and cmd/root.go handleRootError will dispatch through
// WriteTypedErrorEnvelope. This writer is retained only while existing
// *ExitError producers are migrated; it will be removed once they have moved
// to the typed surface.
func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
if err.Detail == nil {
return
@@ -60,6 +94,13 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
// --- Convenience constructors ---
// Errorf creates an ExitError with the given code, type, and formatted message.
//
// Deprecated: Errorf belongs to the legacy *output.ExitError surface that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — construct a typed *errs.XxxError directly (e.g.
// *errs.ValidationError, *errs.InternalError). This helper is retained only
// while existing call sites are migrated; it will be removed once they have
// moved to the typed surface.
func Errorf(code int, errType, format string, args ...any) *ExitError {
var err error
for _, arg := range args {
@@ -75,23 +116,58 @@ func Errorf(code int, errType, format string, args ...any) *ExitError {
}
}
// ErrValidation creates a validation ExitError (exit 2).
// ErrValidation creates a validation ExitError (exit 2, wire type
// "validation"). The legacy *output.ExitError envelope emits only
// `type`+`message` — no `subtype`/`param` extension fields.
//
// Stage-1 status: still acceptable to use in new code that only needs the
// (type, message) pair. To carry extension fields (Subtype, Param, etc.)
// on the wire, construct `&errs.ValidationError{...}` directly so
// cmd/root.go routes it through the typed envelope writer. Per-domain
// typed migration in stage 2+ will migrate existing call sites and
// remove this helper.
func ErrValidation(format string, args ...any) *ExitError {
return Errorf(ExitValidation, "validation", format, args...)
}
// ErrAuth creates an auth ExitError (exit 3).
// ErrAuth creates an authentication ExitError (exit 3, wire type "auth").
//
// Stage-1 status: kept as the canonical helper for token-missing /
// login-required errors, so the 19 existing call sites in cmd/auth,
// cmd/config, cmd/event, internal/client, and shortcuts/common keep
// emitting `type: "auth"`. To migrate a single call site to the typed
// taxonomy (`type: "authentication"` on the wire), construct
// `&errs.AuthenticationError{...}` directly — but note that flips a
// user-visible wire field and belongs in the per-domain stage-2 PR for
// that area, not in unrelated new code.
func ErrAuth(format string, args ...any) *ExitError {
return Errorf(ExitAuth, "auth", format, args...)
}
// ErrNetwork creates a network ExitError (exit 4).
// ErrNetwork creates a network ExitError (exit 4, wire type "network").
// The legacy *output.ExitError envelope emits only `type`+`message` — no
// `subtype`/`cause` extension fields.
//
// Stage-1 status: still acceptable to use in new code that only needs the
// (type, message) pair. To carry extension fields (Subtype "transport" /
// "timeout" / "tls" / "dns", retryable hint, etc.) on the wire, construct
// `&errs.NetworkError{...}` directly. Per-domain typed migration in
// stage 2+ will migrate existing call sites and remove this helper.
func ErrNetwork(format string, args ...any) *ExitError {
return Errorf(ExitNetwork, "network", format, args...)
}
// ErrAPI creates an API ExitError using ClassifyLarkError.
// For permission errors, uses a concise message; the raw API response is preserved in Detail.
//
// Deprecated: ErrAPI belongs to the legacy *output.ExitError surface that
// predates the typed error contract introduced by errs/. New code SHOULD
// construct a typed *errs.XxxError directly. The stage-2+ migration will
// route classification through internal/errclass.BuildAPIError (shipped
// but not yet invoked from production paths) so the typed envelope carries
// Category, Subtype, MissingScopes, ConsoleURL, and Identity from the
// source. This helper is retained only while existing call sites are
// migrated; it will be removed once they have moved to the typed surface.
func ErrAPI(larkCode int, msg string, detail any) *ExitError {
exitCode, errType, hint := ClassifyLarkError(larkCode, msg)
if errType == "permission" {
@@ -110,6 +186,13 @@ func ErrAPI(larkCode int, msg string, detail any) *ExitError {
}
// ErrWithHint creates an ExitError with a hint string.
//
// Deprecated: ErrWithHint belongs to the legacy *output.ExitError surface
// that predates the typed error contract introduced by errs/. New code MUST
// NOT use it — construct a typed *errs.XxxError directly and set its Hint
// field (the typed envelope promotes Problem.Hint to the wire). This helper
// is retained only while existing call sites are migrated; it will be
// removed once they have moved to the typed surface.
func ErrWithHint(code int, errType, msg, hint string) *ExitError {
return &ExitError{
Code: code,
@@ -119,17 +202,62 @@ func ErrWithHint(code int, errType, msg, hint string) *ExitError {
// ErrBare creates an ExitError with only an exit code and no envelope.
// Used for cases like `auth check` where the JSON output is already written to stdout.
//
// Deprecated: ErrBare belongs to the legacy *output.ExitError surface that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — express the "exit with code, emit no envelope" semantics
// explicitly at the call site (e.g. return a typed *errs.XxxError or call
// os.Exit directly from RunE). This helper is retained only while existing
// call sites are migrated; it will be removed once they have moved to the
// typed surface.
func ErrBare(code int) *ExitError {
return &ExitError{Code: code}
}
// MarkRaw sets Raw=true on an ExitError so that enrichment (e.g. enrichPermissionError)
// is skipped and the original API error is preserved. Returns the original error unchanged
// if it is not an ExitError.
func MarkRaw(err error) error {
var exitErr *ExitError
if errors.As(err, &exitErr) {
exitErr.Raw = true
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
// Each typed error owns its wire shape via its own struct tags: Problem fields
// are promoted to the top level through embedding, and extension fields
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
// a `detail` sub-object.
//
// Returns true when err was a typed error (envelope written) and false when
// err had no Problem (caller should fall back to WriteErrorEnvelope).
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
typed, ok := errs.UnwrapTypedError(err)
if !ok {
return false
}
return err
env := typedEnvelope{
OK: false,
Identity: identity,
Error: typed,
Notice: GetNotice(),
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if encErr := enc.Encode(env); encErr != nil {
// Encoding failed — emit nothing here and let the dispatcher fall
// back to the legacy envelope writer so stderr is never blank.
return false
}
if _, writeErr := buf.WriteTo(w); writeErr != nil {
// Write failed mid-envelope. Return false so the dispatcher does
// not silently treat a half-written stderr as a successful emit
// and skip every other fallback.
return false
}
return true
}
// typedEnvelope wraps a typed error for wire emission. Error is `error` so the
// underlying typed error's own json tags determine the inner shape via
// encoding/json reflection; Notice mirrors the existing ErrorEnvelope (see
// GetNotice in envelope.go).
type typedEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error error `json:"error"`
Notice map[string]interface{} `json:"_notice,omitempty"`
}

View File

@@ -6,40 +6,10 @@ package output
import (
"bytes"
"encoding/json"
"fmt"
"errors"
"testing"
)
func TestMarkRaw_ExitError(t *testing.T) {
err := ErrAPI(99991672, "API error: [99991672] scope not enabled", nil)
if err.Raw {
t.Fatal("expected Raw=false before MarkRaw")
}
result := MarkRaw(err)
if result != err {
t.Error("expected MarkRaw to return the same error")
}
if !err.Raw {
t.Error("expected Raw=true after MarkRaw")
}
}
func TestMarkRaw_NonExitError(t *testing.T) {
plain := fmt.Errorf("some plain error")
result := MarkRaw(plain)
if result != plain {
t.Error("expected MarkRaw to return the same error for non-ExitError")
}
}
func TestMarkRaw_Nil(t *testing.T) {
result := MarkRaw(nil)
if result != nil {
t.Error("expected MarkRaw(nil) to return nil")
}
}
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
// Set up PendingNotice
origNotice := PendingNotice
@@ -148,3 +118,89 @@ func TestGetNotice(t *testing.T) {
PendingNotice = origNotice
}
// TestErrValidation_LegacyExitErrorShape pins the stage-1 wire contract for
// output.ErrValidation: the helper MUST return *output.ExitError (so callers
// using errors.As(&exitErr) continue to work), with wire fields restricted
// to type+message — no `subtype` emission. The typed envelope shape (which
// adds subtype, param, etc.) is reserved for stage-2 per-domain migration.
func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
err := ErrValidation("bad arg: %s", "x")
var exitErr *ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("ErrValidation must return *ExitError, got %T", err)
}
if exitErr.Code != ExitValidation {
t.Errorf("Code = %d, want ExitValidation (%d)", exitErr.Code, ExitValidation)
}
if exitErr.Detail == nil {
t.Fatal("Detail must be populated")
}
if exitErr.Detail.Type != "validation" {
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "validation")
}
if exitErr.Detail.Message != "bad arg: x" {
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "bad arg: x")
}
// Wire envelope must have only type+message — no subtype field.
var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "user")
var wire map[string]any
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
}
errObj, ok := wire["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
}
if _, hasSubtype := errObj["subtype"]; hasSubtype {
t.Errorf("legacy ErrValidation envelope must NOT emit `subtype`; got: %s", buf.String())
}
if errObj["type"] != "validation" {
t.Errorf("envelope error.type = %v, want \"validation\"", errObj["type"])
}
}
// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for
// output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation —
// no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork.
func TestErrNetwork_LegacyExitErrorShape(t *testing.T) {
err := ErrNetwork("conn refused: %s", "10.0.0.1")
var exitErr *ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("ErrNetwork must return *ExitError, got %T", err)
}
if exitErr.Code != ExitNetwork {
t.Errorf("Code = %d, want ExitNetwork (%d)", exitErr.Code, ExitNetwork)
}
if exitErr.Detail == nil {
t.Fatal("Detail must be populated")
}
if exitErr.Detail.Type != "network" {
t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "network")
}
if exitErr.Detail.Message != "conn refused: 10.0.0.1" {
t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "conn refused: 10.0.0.1")
}
// Wire envelope must have only type+message — no subtype field.
var buf bytes.Buffer
WriteErrorEnvelope(&buf, exitErr, "user")
var wire map[string]any
if err := json.Unmarshal(buf.Bytes(), &wire); err != nil {
t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String())
}
errObj, ok := wire["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing 'error' object; got: %s", buf.String())
}
if _, hasSubtype := errObj["subtype"]; hasSubtype {
t.Errorf("legacy ErrNetwork envelope must NOT emit `subtype`; got: %s", buf.String())
}
if errObj["type"] != "network" {
t.Errorf("envelope error.type = %v, want \"network\"", errObj["type"])
}
}

View File

@@ -3,6 +3,12 @@
package output
import (
"errors"
"github.com/larksuite/cli/errs"
)
// Fine-grained error types (permission, not_found, rate_limit, etc.)
// are communicated via the JSON error envelope's "type" field,
// not via exit codes.
@@ -16,3 +22,48 @@ const (
ExitContentSafety = 6 // content safety violation (block mode)
ExitConfirmationRequired = 10 // 高风险操作需要 --yes 确认agent 协议信号)
)
// ExitCodeForCategory maps an errs.Category to the shell exit code.
// Multiple categories may share an exit code (Authentication / Authorization /
// Config all map to 3), so the relationship is many-to-one.
func ExitCodeForCategory(cat errs.Category) int {
switch cat {
case errs.CategoryValidation:
return ExitValidation
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig:
return ExitAuth
case errs.CategoryNetwork:
return ExitNetwork
case errs.CategoryAPI:
return ExitAPI
case errs.CategoryPolicy:
return ExitContentSafety
case errs.CategoryInternal:
return ExitInternal
case errs.CategoryConfirmation:
return ExitConfirmationRequired
}
return ExitInternal
}
// ExitCodeOf returns the shell exit code for any error.
// - typed errors (*errs.PermissionError, *errs.APIError, ...) → routed by Category
// - legacy *output.ExitError → uses its own Code field
// - *core.ConfigError → reaches the dispatcher as a legacy
// *output.ExitError via cmd/root asExitError (stage 1); the typed
// promotion path through internal/errcompat.PromoteConfigError is
// reserved for stage 2+.
// - untyped → ExitInternal
func ExitCodeOf(err error) int {
if err == nil {
return ExitOK
}
if _, ok := errs.ProblemOf(err); ok {
return ExitCodeForCategory(errs.CategoryOf(err))
}
var exitErr *ExitError
if errors.As(err, &exitErr) {
return exitErr.Code
}
return ExitInternal
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestExitCodeForCategory(t *testing.T) {
cases := []struct {
name string
cat errs.Category
want int
}{
{"validation", errs.CategoryValidation, 2},
{"authentication", errs.CategoryAuthentication, 3},
{"authorization", errs.CategoryAuthorization, 3},
{"config", errs.CategoryConfig, 3},
{"network", errs.CategoryNetwork, 4},
{"api", errs.CategoryAPI, 1},
{"policy", errs.CategoryPolicy, 6},
{"internal", errs.CategoryInternal, 5},
{"confirmation", errs.CategoryConfirmation, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ExitCodeForCategory(tc.cat); got != tc.want {
t.Errorf("ExitCodeForCategory(%q) = %d, want %d", tc.cat, got, tc.want)
}
})
}
}
func TestExitCodeForCategory_UnknownDefaults(t *testing.T) {
if got := ExitCodeForCategory(errs.Category("not_a_real_category")); got != ExitInternal {
t.Errorf("ExitCodeForCategory(unknown) = %d, want %d (ExitInternal)", got, ExitInternal)
}
}
func TestExitCodeOf_Nil(t *testing.T) {
if got := ExitCodeOf(nil); got != 0 {
t.Errorf("ExitCodeOf(nil) = %d, want 0", got)
}
}
func TestExitCodeOf_PermissionError(t *testing.T) {
err := &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}}
if got := ExitCodeOf(err); got != 3 {
t.Errorf("ExitCodeOf(PermissionError) = %d, want 3", got)
}
}
func TestExitCodeOf_APIError(t *testing.T) {
err := &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}}
if got := ExitCodeOf(err); got != 1 {
t.Errorf("ExitCodeOf(APIError) = %d, want 1", got)
}
}
func TestExitCodeOf_UntypedFallsBackToInternal(t *testing.T) {
if got := ExitCodeOf(fmt.Errorf("plain")); got != 5 {
t.Errorf("ExitCodeOf(plain) = %d, want 5 (untyped → CategoryInternal → ExitInternal)", got)
}
}

View File

@@ -3,8 +3,19 @@
package output
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
)
// Lark API generic error code constants.
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
//
// Kept as exported identifiers because external shortcut packages reference
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
// Subtype / Retryable metadata for each code lives in internal/errclass and
// must remain the single source of truth — ClassifyLarkError below resolves
// classification through errclass.LookupCodeMeta.
const (
// Auth: token missing / invalid / expired.
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
@@ -32,7 +43,6 @@ const (
LarkErrRefreshExpired = 20037 // refresh_token expired
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
@@ -58,59 +68,159 @@ const (
LarkErrOwnershipMismatch = 231205
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
// errType provides fine-grained classification in the JSON envelope;
// exitCode is kept coarse (ExitAuth or ExitAPI).
func ClassifyLarkError(code int, msg string) (int, string, string) {
switch code {
// auth: token missing / invalid / expired
case LarkErrTokenMissing, LarkErrTokenBadFmt:
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
case LarkErrTokenInvalid, LarkErrATInvalid, LarkErrTokenExpired:
return ExitAuth, "auth", "run: lark-cli auth login to re-authorize"
// legacyHints supplies the per-code actionable hint string for the legacy
// (exitCode, errType, hint) tuple returned by ClassifyLarkError. Hint
// composition is not yet centralized in errclass (the canonical
// PermissionHint lives there but the long-form per-code hints below are
// still wire-stable strings), so this small lookup remains here. Codes
// absent from this map fall back to "".
var legacyHints = map[int]string{
LarkErrTokenMissing: "run: lark-cli auth login to re-authorize",
LarkErrTokenBadFmt: "run: lark-cli auth login to re-authorize",
LarkErrTokenInvalid: "run: lark-cli auth login to re-authorize",
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
// permission: scope not granted
case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission,
LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized:
return ExitAPI, "permission", "check app permissions or re-authorize: lark-cli auth login"
LarkErrAppScopeNotEnabled: "check app permissions or re-authorize: lark-cli auth login",
LarkErrTokenNoPermission: "check app permissions or re-authorize: lark-cli auth login",
LarkErrUserScopeInsufficient: "check app permissions or re-authorize: lark-cli auth login",
LarkErrUserNotAuthorized: "check app permissions or re-authorize: lark-cli auth login",
// app credential / status
case LarkErrAppCredInvalid:
return ExitAuth, "config", "check app_id / app_secret: lark-cli config set"
case LarkErrAppNotInUse, LarkErrAppUnauthorized:
return ExitAuth, "app_status", "app is disabled or not installed — check developer console"
LarkErrAppCredInvalid: "check app_id / app_secret: lark-cli config set",
LarkErrAppNotInUse: "app is disabled or not installed — check developer console",
LarkErrAppUnauthorized: "app is disabled or not installed — check developer console",
// rate limit
case LarkErrRateLimit:
return ExitAPI, "rate_limit", "please try again later"
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrWikiLockContention:
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
// sheets-specific constraints that benefit from actionable hints
case LarkErrSheetsFloatImageInvalidDims:
return ExitAPI, "invalid_params",
"check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
// drive permission-apply specific guidance
case LarkErrDrivePermApplyRateLimit:
return ExitAPI, "rate_limit",
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
case LarkErrDrivePermApplyNotApplicable:
return ExitAPI, "invalid_params",
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
case LarkErrOwnershipMismatch:
return ExitAPI, "ownership_mismatch", buildOwnershipRecoveryHint()
}
return ExitAPI, "api_error", ""
LarkErrRateLimit: "please try again later",
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
LarkErrWikiLockContention: "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes",
LarkErrDriveCrossTenantUnit: "operate on source and target within the same tenant and region/unit",
LarkErrDriveCrossBrand: "operate on source and target within the same brand environment",
LarkErrSheetsFloatImageInvalidDims: "check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height",
LarkErrDrivePermApplyRateLimit: "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly",
LarkErrDrivePermApplyNotApplicable: "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly",
}
// ClassifyLarkError maps a Lark API error code + message to the legacy
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
//
// Classification (Category / Subtype) is sourced from
// errclass.LookupCodeMeta — the single source of truth shipped for both
// this legacy adapter and the stage-2+ typed pipeline (errclass.BuildAPIError,
// not yet invoked in production). This function adapts that result back to
// the legacy tuple shape for callers that still go through *ExitError:
//
// - exitCode: derived from (Category, Subtype) via legacyExitCode below.
// Note this differs from the typed pipeline's ExitCodeForCategory in
// two preserved-legacy-quirks: Authorization+permission subtypes return
// ExitAPI (legacy treats "permission" as exit 1) and Config returns
// ExitAuth (legacy bundles "check app_id/secret" under exit 3).
// - errType: legacy short string per (Category, Subtype), mapped by
// legacyErrType. Subtypes not present in the legacy taxonomy fall back
// to "api_error".
// - hint: per-code lookup in legacyHints; "" when absent.
//
// Unknown codes (LookupCodeMeta returns false) classify as
// (ExitAPI, "api_error", "") — matching the prior default.
//
// Deprecated: ClassifyLarkError belongs to the legacy *output.ExitError
// surface that predates the typed error contract introduced by errs/. New
// code MUST NOT use it — classify Lark API responses via
// internal/errclass.BuildAPIError, which emits a typed *errs.XxxError with
// Category, Subtype, and identity-aware extension fields populated at the
// source. This helper is retained only while existing call sites are
// migrated; it will be removed once they have moved to the typed surface.
func ClassifyLarkError(code int, msg string) (int, string, string) {
meta, ok := errclass.LookupCodeMeta(code)
if !ok {
return ExitAPI, "api_error", ""
}
exitCode := legacyExitCode(meta.Category, meta.Subtype)
errType := legacyErrType(meta.Category, meta.Subtype)
hint := legacyHints[code]
// IM ownership mismatch keeps its dynamic recovery hint.
if code == LarkErrOwnershipMismatch {
hint = buildOwnershipRecoveryHint()
}
return exitCode, errType, hint
}
// legacyExitCode maps (Category, Subtype) to the legacy *ExitError exit
// code. It diverges from ExitCodeForCategory in two places to preserve the
// historic wire:
//
// - CategoryAuthorization with a "permission" subtype (missing_scope,
// app_scope_not_enabled, token_no_permission) → ExitAPI (1), not
// ExitAuth (3). Legacy considered permission failures a generic API
// refusal.
// - CategoryConfig → ExitAuth (3). Legacy bundled "check app_id/secret"
// under the auth bucket.
func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
switch cat {
case errs.CategoryAuthentication:
return ExitAuth
case errs.CategoryAuthorization:
switch sub {
case errs.SubtypeMissingScope,
errs.SubtypeUserUnauthorized,
errs.SubtypeAppScopeNotApplied,
errs.SubtypeTokenScopeInsufficient:
return ExitAPI
case errs.SubtypeAppUnavailable,
errs.SubtypeAppNotInstalled:
return ExitAuth
}
return ExitAPI
case errs.CategoryConfig:
return ExitAuth
}
return ExitAPI
}
// legacyErrType maps (Category, Subtype) to the legacy *ExitError errType
// string (e.g. "permission", "rate_limit"). Subtypes outside the
// historically-classified set fall back to "api_error", matching the prior
// default-case behavior.
func legacyErrType(cat errs.Category, sub errs.Subtype) string {
switch cat {
case errs.CategoryAuthentication:
return "auth"
case errs.CategoryAuthorization:
switch sub {
case errs.SubtypeMissingScope,
errs.SubtypeUserUnauthorized,
errs.SubtypeAppScopeNotApplied,
errs.SubtypeTokenScopeInsufficient:
return "permission"
case errs.SubtypeAppUnavailable,
errs.SubtypeAppNotInstalled:
return "app_status"
}
return "permission"
case errs.CategoryConfig:
switch sub {
case errs.SubtypeInvalidClient,
errs.SubtypeNotConfigured,
errs.SubtypeInvalidConfig:
return "config"
}
return "config"
case errs.CategoryAPI:
switch sub {
case errs.SubtypeRateLimit:
return "rate_limit"
case errs.SubtypeConflict:
return "conflict"
case errs.SubtypeCrossTenant:
return "cross_tenant"
case errs.SubtypeCrossBrand:
return "cross_brand"
case errs.SubtypeInvalidParameters:
return "invalid_parameters"
case errs.SubtypeOwnershipMismatch:
return "ownership_mismatch"
}
return "api_error"
}
return "api_error"
}

View File

@@ -30,7 +30,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
name: "cross tenant unit",
code: LarkErrDriveCrossTenantUnit,
wantExitCode: ExitAPI,
wantType: "cross_tenant_unit",
wantType: "cross_tenant",
wantHint: "same tenant and region/unit",
},
{
@@ -44,7 +44,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
name: "sheets float image invalid dims",
code: LarkErrSheetsFloatImageInvalidDims,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantType: "invalid_parameters",
wantHint: "--width / --height / --offset-x / --offset-y",
},
{
@@ -58,7 +58,7 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
name: "drive permission apply not applicable",
code: LarkErrDrivePermApplyNotApplicable,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantType: "invalid_parameters",
wantHint: "does not accept a permission-apply request",
},
{