From 99e314fe0bb177810f3e8bef81e957136bd7bb14 Mon Sep 17 00:00:00 2001 From: evandance <120630830+evandance@users.noreply.github.com> Date: Sat, 30 May 2026 19:08:41 +0800 Subject: [PATCH] feat(errs): typed envelope contract for auth-domain errors (#1135) Every failure on the authentication, authorization, and configuration path now surfaces as a typed structured error instead of an ad-hoc envelope. Users and scripts that consume CLI output get: - a fixed nine-category taxonomy on the wire, each mapped to a stable shell exit code (authentication/authorization/config = 3, network = 4, internal = 5, policy = 6, confirmation = 10) - identity-aware detail fields (missing_scopes, requested_scopes, granted_scopes, console_url, log_id, retryable, hint) carried uniformly on the envelope - a single canonical policy envelope at exit 6; the legacy auth_error carve-out is retired - per-subtype canonical message + hint that preserves Lark's diagnostic phrasing and routes recovery to the right actor: app developer (app_scope_not_applied), user (missing_scope, token_scope_insufficient, user_unauthorized), or tenant admin (app_unavailable, app_disabled) - wrong app credentials classify as config/invalid_client whether surfaced by the Open API endpoint (99991543) or the tenant access-token mint endpoint (10003 / 10014), instead of collapsing to a transport error or api/unknown - local shortcut scope preflight emits the same authorization/missing_scope envelope (identity + deterministic missing-scope set) used by the post-call permission path, so AI consumers read the same structured shape from precheck and from server-returned permission denial - streaming download/upload failures keep the same network subtype split (timeout / TLS / DNS / transport) as the non-stream path instead of collapsing every cause to a generic transport failure - console_url is carried only on the bot-perspective app_scope_not_applied envelope (where the recovery action is "developer applies the scope at the developer console"); the user-perspective missing_scope envelope drops the field, since the only actionable user recovery is `lark-cli auth login --scope` and pointing an end user at a console they cannot modify is misleading - bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic Type tags to wire 'config' with the original module name kept as a metric label All 10 typed errors are cause-bearing, nil-safe on .Error() and .Unwrap(), and defensively clone slice setter inputs. Four lint rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry / CheckBuildAPIErrorArms) lock these invariants on migrated paths. --- .github/workflows/ci.yml | 2 + .golangci.yml | 21 +- cmd/api/api.go | 20 +- cmd/api/api_test.go | 48 ++ cmd/auth/auth.go | 30 +- cmd/auth/auth_test.go | 60 +- cmd/auth/check.go | 3 +- cmd/auth/check_test.go | 167 +++++ cmd/auth/login.go | 64 +- cmd/auth/login_interactive.go | 3 +- cmd/auth/login_result.go | 26 +- cmd/auth/login_result_test.go | 61 ++ cmd/auth/login_test.go | 111 ++- cmd/auth/logout.go | 3 +- cmd/auth/qrcode.go | 20 +- cmd/auth/qrcode_test.go | 72 +- cmd/auth/scopes.go | 21 +- cmd/auth/scopes_test.go | 121 ++++ cmd/config/bind.go | 33 +- cmd/config/bind_test.go | 114 +-- cmd/config/binder.go | 116 ++-- cmd/config/binder_test.go | 14 +- cmd/config/config_test.go | 23 +- cmd/config/default_as.go | 6 +- cmd/config/init.go | 84 ++- cmd/config/init_interactive.go | 21 +- cmd/config/init_test.go | 133 ++++ cmd/config/keychain_downgrade.go | 11 +- cmd/config/keychain_downgrade_other.go | 4 +- cmd/config/remove.go | 5 +- cmd/config/show.go | 5 +- cmd/config/strict_mode.go | 12 +- cmd/error_auth_hint.go | 58 +- cmd/root.go | 196 +++--- cmd/root_integration_test.go | 6 +- cmd/root_test.go | 361 ++++++++-- cmd/service/service.go | 104 +-- errs/ERROR_CONTRACT.md | 233 ++++--- errs/marshal_test.go | 97 ++- errs/predicates.go | 9 + errs/problem.go | 19 +- errs/subtypes.go | 20 +- errs/subtypes_service_task.go | 21 - errs/types.go | 655 +++++++++++++++++- errs/types_test.go | 480 ++++++++++++- internal/auth/uat_client.go | 2 +- internal/auth/verify.go | 2 +- internal/client/api_errors.go | 141 ++-- internal/client/api_errors_test.go | 389 +++++++---- internal/client/client.go | 111 +-- internal/client/client_test.go | 195 ++++-- internal/client/dostream_test.go | 13 +- internal/client/response.go | 60 +- internal/client/response_test.go | 19 +- internal/cmdutil/factory.go | 39 +- internal/cmdutil/factory_test.go | 31 +- internal/credential/default_provider.go | 33 +- internal/credential/default_provider_test.go | 68 ++ internal/errclass/classify.go | 193 +++++- internal/errclass/classify_internal_test.go | 136 ++++ internal/errclass/classify_test.go | 420 ++++++++--- internal/errclass/codemeta.go | 63 +- internal/errclass/codemeta_task.go | 27 +- internal/errclass/codemeta_test.go | 60 +- internal/errcompat/promote.go | 50 +- internal/errcompat/promote_auth.go | 32 + internal/errcompat/promote_auth_test.go | 79 +++ internal/errcompat/promote_test.go | 102 ++- internal/output/errors.go | 126 ++-- internal/output/errors_test.go | 50 +- internal/output/lark_errors.go | 64 +- internal/registry/helpers.go | 39 ++ .../errscontract/rule_build_api_error_arms.go | 276 ++++++++ lint/errscontract/rule_builder_immutable.go | 163 +++++ lint/errscontract/rule_new_invariants_test.go | 600 ++++++++++++++++ lint/errscontract/rule_nil_safe_error.go | 180 +++++ lint/errscontract/rule_unwrap_symmetry.go | 68 ++ lint/errscontract/runner.go | 7 + lint/errscontract/scan.go | 5 + lint/errscontract/scan_test.go | 8 +- scripts/check-skill-wire-vocab.sh | 11 + shortcuts/base/record_upload_attachment.go | 61 +- shortcuts/calendar/helpers.go | 4 +- shortcuts/common/mcp_client.go | 75 +- shortcuts/common/mcp_client_test.go | 15 +- shortcuts/common/runner.go | 42 +- shortcuts/common/runner_scope_test.go | 135 ++-- shortcuts/drive/drive_status_test.go | 26 +- shortcuts/mail/helpers.go | 25 +- .../mail/mail_confirm_send_scope_test.go | 15 +- shortcuts/vc/vc_notes.go | 9 +- skills/lark-apps/SKILL.md | 4 +- .../references/lark-apps-access-scope-get.md | 2 +- .../references/lark-apps-access-scope-set.md | 2 +- .../lark-apps/references/lark-apps-create.md | 4 +- .../references/lark-apps-html-publish.md | 12 +- skills/lark-apps/references/lark-apps-list.md | 2 +- .../lark-apps/references/lark-apps-update.md | 2 +- skills/lark-slides/references/examples.md | 2 +- tests/cli_e2e/config/bind_test.go | 31 +- 100 files changed, 6323 insertions(+), 1700 deletions(-) create mode 100644 cmd/auth/check_test.go create mode 100644 cmd/auth/login_result_test.go create mode 100644 cmd/auth/scopes_test.go create mode 100644 cmd/config/init_test.go delete mode 100644 errs/subtypes_service_task.go create mode 100644 internal/errclass/classify_internal_test.go create mode 100644 internal/errcompat/promote_auth.go create mode 100644 internal/errcompat/promote_auth_test.go create mode 100644 lint/errscontract/rule_build_api_error_arms.go create mode 100644 lint/errscontract/rule_builder_immutable.go create mode 100644 lint/errscontract/rule_new_invariants_test.go create mode 100644 lint/errscontract/rule_nil_safe_error.go create mode 100644 lint/errscontract/rule_unwrap_symmetry.go create mode 100755 scripts/check-skill-wire-vocab.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37745d6b..3349fb44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,8 @@ jobs: run: python3 scripts/fetch_meta.py - name: Run golangci-lint run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main + - name: Run errs/ lint guards (lintcheck) + run: go run -C lint . .. coverage: needs: fast-gate diff --git a/.golangci.yml b/.golangci.yml index c15ebe08..2a16994f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -49,18 +49,26 @@ linters: - gocritic - depguard - forbidigo - - path-except: (shortcuts/|internal/) + # Paths that run forbidigo. Add an entry when a path joins one of + # the rules below. + - path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/) linters: - forbidigo - path: internal/vfs/ linters: - forbidigo - # The shortcuts-no-raw-http forbidigo rule below is shortcuts-only; - # internal/ legitimately wraps raw HTTP for the client / credential layer. + # shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP + # for the client / credential layer. - path-except: shortcuts/ text: shortcuts-no-raw-http linters: - forbidigo + # errs-typed-only enforced on paths already migrated to errs.NewXxxError. + # Add a path when its migration is complete. + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go) + text: errs-typed-only + linters: + - forbidigo settings: depguard: @@ -79,6 +87,13 @@ linters: Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. forbidigo: forbid: + # ── legacy output.Err* helpers banned on migrated paths ── + # output.ErrBare is intentionally not listed — it is the predicate- + # command silent-exit signal, outside the typed envelope contract. + - pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b + msg: >- + [errs-typed-only] use errs.NewXxxError(...) builder + (see errs/types.go). # ── http: shortcuts must not construct raw HTTP requests ── # Bans request / client construction; constants (http.MethodPost, # http.StatusOK) and pure helpers (http.StatusText, http.Header) are diff --git a/cmd/api/api.go b/cmd/api/api.go index 4c7712fe..057493d4 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -238,10 +238,10 @@ func apiRun(opts *APIOptions) error { resp, err := ac.DoAPI(opts.Ctx, request) if err != nil { - // MarkRaw tells the dispatcher to skip enrichPermissionError so the - // raw API error detail (log_id, troubleshooter, permission_violations) - // stays on the wire — `lark-cli api` callers explicitly want the raw - // envelope. + // MarkRaw tells the dispatcher to skip the legacy enrichPermissionError + // pass on *output.ExitError values. Typed *errs.* errors that flow + // through here keep their canonical message / hint from BuildAPIError; + // MarkRaw is a no-op on those (it only flips a flag on *ExitError). return output.MarkRaw(err) } err = client.HandleResponse(resp, client.ResponseOptions{ @@ -253,14 +253,14 @@ func apiRun(opts *APIOptions) error { FileIO: f.ResolveFileIO(opts.Ctx), CommandPath: opts.Cmd.CommandPath(), Identity: opts.As, - // Stage 1: CheckResponse emits the legacy *output.ExitError envelope. - // Per-domain migration in stage 2+ will route through - // errclass.BuildAPIError to populate identity-aware fields - // (PermissionError.ConsoleURL needs Brand+AppID from the client). + // CheckResponse routes through errclass.BuildAPIError for known Lark + // codes (typed PermissionError / AuthenticationError / ...). For + // unknown codes it falls back to *errs.APIError. The Brand+AppID on + // the client populate identity-aware fields (ConsoleURL etc.). CheckError: ac.CheckResponse, }) - // MarkRaw: see comment above on the DoAPI path. Applies equally to - // HandleResponse failures so the raw API error survives to the wire. + // MarkRaw: see comment above on the DoAPI path. Skips legacy + // *ExitError enrichment; typed errors flow through unchanged. if err != nil { return output.MarkRaw(err) } diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 8488c84a..a966f143 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -4,11 +4,13 @@ package api import ( + "errors" "os" "sort" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -670,3 +672,49 @@ func TestApiCmd_DryRunWithFile(t *testing.T) { t.Errorf("expected dry-run header, got: %s", out) } } + +// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark +// API returns a missing-scope failure, the typed *errs.PermissionError +// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError +// consumed during classification into first-class wire fields +// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope +// — there is no raw-payload passthrough; new Lark diagnostic fields require +// a CLI release. +func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/docx/v1/documents/test", + Body: map[string]interface{}{ + "code": 99991679, + "msg": "scope missing", + "log_id": "20260527-test-log", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "docx:document"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for non-zero code") + } + + var pe *errs.PermissionError + if !errors.As(err, &pe) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + + if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" { + t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes) + } + if pe.LogID != "20260527-test-log" { + t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log") + } +} diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 5c5e1c72..288f16de 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -17,6 +17,7 @@ import ( larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" ) // NewCmdAuth creates the auth command with subcommands. @@ -70,7 +71,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope var resp userInfoResponse if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { - return "", "", fmt.Errorf("failed to parse user info: %v", err) + return "", "", fmt.Errorf("failed to parse user info: %w", err) } if resp.Code != 0 { return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg) @@ -110,6 +111,11 @@ type appInfoResponse struct { } `json:"data"` } +// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests +// can substitute a fake without standing up a full SDK + httpmock pipeline. +// Mirrors the pollDeviceToken pattern in login.go. +var getAppInfoFn = getAppInfo + // getAppInfo queries app info from the Lark API. func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { ac, err := f.NewAPIClient() @@ -131,10 +137,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo var resp appInfoResponse if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { - return nil, fmt.Errorf("failed to parse response: %v", err) + return nil, fmt.Errorf("failed to parse response: %w", err) } if resp.Code != 0 { - return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg) + return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId) } app := resp.Data.App @@ -153,3 +159,21 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil } + +// classifyAppInfoErr re-decodes the raw body so BuildAPIError sees the +// upstream `error` block — the typed appInfoResponse shape drops it. +func classifyAppInfoErr(rawBody []byte, code int, msg string, f *cmdutil.Factory, appId string) error { + var raw map[string]any + _ = json.Unmarshal(rawBody, &raw) + if raw == nil { + raw = map[string]any{} + } + raw["code"] = code + raw["msg"] = msg + cc := errclass.ClassifyContext{Identity: string(core.AsBot)} + if cfg, _ := f.Config(); cfg != nil { + cc.Brand = string(cfg.Brand) + cc.AppID = appId + } + return errclass.BuildAPIError(raw, cc) +} diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index ba0d48e6..996c71c6 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -318,6 +319,54 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) } } +// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when +// the Lark API returns a permission code (99991679 with permission_violations), +// getAppInfo classifies it as *errs.PermissionError carrying the server- +// supplied MissingScopes — not a bare error wrapped as InternalError. +func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + tokenResolver := &authScopesTokenResolver{} + f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil) + + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/application/v6/applications/test-app", + Body: map[string]interface{}{ + "code": 99991679, + "msg": "scope missing", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "application:application:self_manage"}, + }, + }, + }, + }) + + err := authScopesRun(&ScopesOptions{ + Factory: f, + Ctx: context.Background(), + Format: "json", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + + var pe *errs.PermissionError + if !errors.As(err, &pe) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" { + t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes) + } + + var intErr *errs.InternalError + if errors.As(err, &intErr) { + t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost") + } +} + type authScopesTokenResolver struct { requests []credential.TokenSpec } @@ -389,15 +438,8 @@ func TestAuthBlockedByExternalProvider(t *testing.T) { if matched != nil && matched != cmd && !matched.SilenceUsage { t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" { - t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } }) } diff --git a/cmd/auth/check.go b/cmd/auth/check.go index 8f7072aa..50c47d10 100644 --- a/cmd/auth/check.go +++ b/cmd/auth/check.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" @@ -47,7 +48,7 @@ func authCheckRun(opts *CheckOptions) error { required := strings.Fields(opts.Scope) if len(required) == 0 { - return output.ErrValidation("--scope cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope") } config, err := f.Config() diff --git a/cmd/auth/check_test.go b/cmd/auth/check_test.go new file mode 100644 index 00000000..ea69174c --- /dev/null +++ b/cmd/auth/check_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "errors" + "testing" + "time" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/zalando/go-keyring" +) + +// `lark-cli auth check` is a predicate command: its README contract is +// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays +// empty so callers can write `if lark-cli auth check ...; then ... fi` +// without their logs getting polluted by an error envelope on the negative +// branch. These tests pin that contract end-to-end through the dispatcher. + +func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) { + f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + // UserOpenId left empty: triggers the not_logged_in branch. + }) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"}) + + if got := output.ExitCodeOf(err); got != 1 { + t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got) + } + var bare *output.ExitError + if !errors.As(err, &bare) { + t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err) + } + if bare.Detail != nil { + t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail) + } + + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String()) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != false { + t.Errorf("stdout.ok = %v, want false", payload["ok"]) + } + if payload["error"] != "not_logged_in" { + t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"]) + } +} + +func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) { + f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + UserOpenId: "ou_user", UserName: "tester", + }) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"}) + + if got := output.ExitCodeOf(err); got != 1 { + t.Errorf("exit code = %d, want 1", got) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty, got:\n%s", stderr.String()) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v", err) + } + if payload["ok"] != false { + t.Errorf("stdout.ok = %v, want false", payload["ok"]) + } + if payload["error"] != "no_token" { + t.Errorf("stdout.error = %v, want 'no_token'", payload["error"]) + } +} + +func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) { + // Predicate command happy path: stored token covers every required + // scope. Exit must be 0 (nil error, not ErrBare), stdout carries the + // `{"ok":true,...}` JSON answer, and stderr stays empty so shell + // callers can rely on `if lark-cli auth check ...; then` without log + // pollution. Pairs with the two exit-1 negatives above so both + // branches of the predicate contract are pinned. + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + + cfg := &core.CliConfig{ + AppID: "test-app", + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_user", + UserName: "tester", + } + now := time.Now() + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: cfg.AppID, + UserOpenId: cfg.UserOpenId, + AccessToken: "user-access-token", + RefreshToken: "refresh-token", + ExpiresAt: now.Add(time.Hour).UnixMilli(), + RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(), + GrantedAt: now.Add(-time.Hour).UnixMilli(), + Scope: "im:message docx:document", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"}) + + if err != nil { + t.Fatalf("expected nil error for happy path (exit 0), got %v", err) + } + if got := output.ExitCodeOf(err); got != 0 { + t.Errorf("exit code = %d, want 0", got) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String()) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + granted, ok := payload["granted"].([]any) + if !ok || len(granted) != 1 || granted[0] != "im:message" { + t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"]) + } + if payload["missing"] != nil { + t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"]) + } + if _, has := payload["suggestion"]; has { + t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"]) + } +} + +func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) { + // Scope validation is a real input error, not a predicate negative + // answer — it must surface as a typed ValidationError with the normal + // stderr envelope, distinct from the silent ErrBare predicate path. + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + err := authCheckRun(&CheckOptions{Factory: f, Scope: " "}) + if err == nil { + t.Fatal("expected validation error for empty --scope") + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f1e6a467..b06fc343 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -13,6 +13,8 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -54,9 +56,9 @@ run --device-code in a later step after the user confirms authorization. Use 'la to generate QR codes (supports ASCII and PNG formats).`, RunE: func(cmd *cobra.Command, args []string) error { if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { - return output.ErrWithHint(output.ExitValidation, "command_denied", - fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode), - "if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "strict mode is %q, user login is disabled in this profile", mode). + WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } opts.Ctx = cmd.Context() if runF != nil { @@ -158,14 +160,14 @@ func authLoginRun(opts *LoginOptions) error { for _, d := range selectedDomains { if !knownDomains[d] { if suggestion := suggestDomain(d, knownDomains); suggestion != "" { - return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain") } available := make([]string, 0, len(knownDomains)) for k := range knownDomains { available = append(available, k) } sort.Strings(available) - return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain") } } } @@ -173,7 +175,7 @@ func authLoginRun(opts *LoginOptions) error { hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0 if len(opts.Exclude) > 0 && !hasAnyOption { - return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude") } if !hasAnyOption { @@ -183,7 +185,7 @@ func authLoginRun(opts *LoginOptions) error { return err } if result == nil { - return output.ErrValidation("no login options selected") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected") } selectedDomains = result.Domains scopeLevel = result.ScopeLevel @@ -199,7 +201,7 @@ func authLoginRun(opts *LoginOptions) error { log(msg.HintFooter) log("") log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.") - return output.ErrValidation("please specify the scopes to authorize") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope") } } @@ -228,7 +230,7 @@ func authLoginRun(opts *LoginOptions) error { } if len(candidateScopes) == 0 && opts.Scope == "" { - return output.ErrValidation("no matching scopes found, check domain/scope options") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options") } // Merge --scope additively with the resolved domain scopes. @@ -248,13 +250,13 @@ func authLoginRun(opts *LoginOptions) error { if len(opts.Exclude) > 0 { excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude) if len(unknown) > 0 { - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "these --exclude scopes are not present in the requested set: %s", - strings.Join(unknown, ", ")) + strings.Join(unknown, ", ")).WithParam("--exclude") } finalScope = excluded if strings.TrimSpace(finalScope) == "" { - return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude") } } @@ -265,7 +267,7 @@ func authLoginRun(opts *LoginOptions) error { } authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut) if err != nil { - return output.ErrAuth("device authorization failed: %v", err) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err) } // --no-wait: return immediately with device code and URL @@ -282,7 +284,7 @@ func authLoginRun(opts *LoginOptions) error { encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) if err := encoder.Encode(data); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) } return nil } @@ -304,7 +306,7 @@ func authLoginRun(opts *LoginOptions) error { encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) if err := encoder.Encode(data); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) } } else { fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) @@ -325,25 +327,25 @@ func authLoginRun(opts *LoginOptions) error { "event": "authorization_failed", "error": result.Message, }); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) } return output.ErrBare(output.ExitAuth) } - return output.ErrAuth("authorization failed: %s", result.Message) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message) } if result.Token == nil { - return output.ErrAuth("authorization succeeded but no token returned") + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") } // Step 6: Get user info log(msg.AuthSuccess) sdk, err := f.LarkClient() if err != nil { - return output.ErrAuth("failed to get SDK: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) } openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) if err != nil { - return output.ErrAuth("failed to get user info: %v", err) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) } scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope) @@ -361,13 +363,13 @@ func authLoginRun(opts *LoginOptions) error { GrantedAt: now, } if err := larkauth.SetStoredToken(storedToken); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err) } // Step 8: Update config — overwrite Users to single user, clean old tokens if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { _ = larkauth.RemoveStoredToken(config.AppID, openId) - return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err) + return err } if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil { @@ -410,22 +412,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo if shouldRemoveLoginRequestedScope(result) { cleanupRequestedScope() } - return output.ErrAuth("authorization failed: %s", result.Message) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message) } defer cleanupRequestedScope() if result.Token == nil { - return output.ErrAuth("authorization succeeded but no token returned") + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned") } // Get user info log(msg.AuthSuccess) sdk, err := f.LarkClient() if err != nil { - return output.ErrAuth("failed to get SDK: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err) } openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) if err != nil { - return output.ErrAuth("failed to get user info: %v", err) + return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err) } scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope) @@ -443,13 +445,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo GrantedAt: now, } if err := larkauth.SetStoredToken(storedToken); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err) } // Update config — overwrite Users to single user, clean old tokens if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil { _ = larkauth.RemoveStoredToken(config.AppID, openId) - return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err) } if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil { @@ -464,18 +466,18 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo func syncLoginUserToProfile(profileName, appID, openID, userName string) error { multi, err := core.LoadMultiAppConfig() if err != nil { - return fmt.Errorf("load config: %w", err) + return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err) } app := findProfileByName(multi, profileName) if app == nil { - return fmt.Errorf("profile %q not found in config", profileName) + return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName) } oldUsers := append([]core.AppUser(nil), app.Users...) app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}} if err := core.SaveMultiAppConfig(multi); err != nil { - return fmt.Errorf("save config: %w", err) + return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err) } for _, oldUser := range oldUsers { diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 0510f82c..a68efc14 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/huh" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -162,7 +163,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra } if len(selectedDomains) == 0 { - return nil, output.ErrValidation("no domains selected") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain") } // Compute scope summary diff --git a/cmd/auth/login_result.go b/cmd/auth/login_result.go index db609d6c..84eccc97 100644 --- a/cmd/auth/login_result.go +++ b/cmd/auth/login_result.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" @@ -171,25 +172,12 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory fmt.Fprintln(f.IOStreams.Out, string(b)) return output.ErrBare(output.ExitAuth) } - detail := map[string]interface{}{ - "requested": issue.Summary.Requested, - "granted": issue.Summary.Granted, - "missing": issue.Summary.Missing, - } - // Legacy *output.ExitError producer: this literal predates the typed - // error contract introduced by errs/. New code MUST NOT construct - // *output.ExitError directly — missing-scope signals should move to - // *errs.PermissionError (with MissingScopes/ConsoleURL as typed - // extension fields) when the login flow migrates to typed errors. - return &output.ExitError{ - Code: output.ExitAuth, - Detail: &output.ErrDetail{ - Type: "missing_scope", - Message: issue.Message, - Hint: issue.Hint, - Detail: detail, - }, - } + return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message). + WithHint("%s", issue.Hint). + WithIdentity("user"). + WithRequestedScopes(issue.Summary.Requested...). + WithGrantedScopes(issue.Summary.Granted...). + WithMissingScopes(issue.Summary.Missing...) } fmt.Fprintln(f.IOStreams.ErrOut) diff --git a/cmd/auth/login_result_test.go b/cmd/auth/login_result_test.go new file mode 100644 index 00000000..5f14d40d --- /dev/null +++ b/cmd/auth/login_result_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "reflect" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" +) + +// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the +// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires +// requested + granted + missing scopes into the typed *PermissionError +// envelope. Consumers need the full triple to render actionable diagnostics, +// not just the missing set. +func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + requested := []string{"docx:document", "im:message:send"} + granted := []string{"docx:document"} + missing := []string{"im:message:send"} + + err := handleLoginScopeIssue( + &LoginOptions{JSON: true}, + getLoginMsg("en"), + f, + &loginScopeIssue{ + Message: "scope insufficient", + Hint: "re-login with --scope im:message:send", + Summary: &loginScopeSummary{ + Requested: requested, + Granted: granted, + Missing: missing, + }, + }, + "", // openId empty -> loginSucceeded = false + "tester", + ) + + if err == nil { + t.Fatal("expected error, got nil") + } + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + if !reflect.DeepEqual(permErr.RequestedScopes, requested) { + t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested) + } + if !reflect.DeepEqual(permErr.GrantedScopes, granted) { + t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted) + } + if !reflect.DeepEqual(permErr.MissingScopes, missing) { + t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing) + } +} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 41618509..d0a73fe9 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -400,12 +400,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) { Granted: []string{"base:app:copy"}, }, }, "ou_user", "tester") - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) } got := stderr.String() for _, want := range []string{ @@ -443,12 +442,11 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) { Granted: []string{"base:app:copy"}, }, }, "ou_user", "tester") - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) } var data map[string]interface{} @@ -653,12 +651,11 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) Ctx: context.Background(), Scope: "im:message:send", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) } got := stderr.String() for _, want := range []string{ @@ -870,6 +867,90 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { } } +// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the +// contract that when --json is set and pollDeviceToken returns OK=false, +// stdout carries the structured authorization_failed event and stderr is +// NOT polluted with a typed envelope. The returned error is a bare +// ExitError with ExitAuth so the dispatcher only propagates the exit code +// without emitting a second envelope on top of the JSON event. +func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + + original := pollDeviceToken + t.Cleanup(func() { pollDeviceToken = original }) + pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult { + return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"} + } + + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathDeviceAuthorization, + Body: map[string]interface{}{ + "device_code": "device-code", + "user_code": "user-code", + "verification_uri": "https://example.com/verify", + "verification_uri_complete": "https://example.com/verify?code=123", + "expires_in": 240, + "interval": 0, + }, + }) + + err := authLoginRun(&LoginOptions{ + Factory: f, + Ctx: context.Background(), + Scope: "im:message:send", + JSON: true, + }) + if err == nil { + t.Fatal("expected error for aborted authorization") + } + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth) + } + + // stdout: device_authorization event + authorization_failed event, + // the latter carrying the abort message as a structured field. + stdoutStr := stdout.String() + if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) { + t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr) + } + if !strings.Contains(stdoutStr, "user denied") { + t.Errorf("stdout missing abort message, got: %s", stdoutStr) + } + + // stderr must NOT carry a typed envelope: ErrBare propagates the exit + // code only, so the dispatcher emits nothing on stderr. The waiting-auth + // log line goes through the JSON-mode no-op `log` helper so it is also + // suppressed in JSON mode. + stderrStr := stderr.String() + if strings.Contains(stderrStr, `"type":"authentication"`) { + t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr) + } + if strings.Contains(stderrStr, `"error"`) { + t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr) + } + + // Returned error must be the bare *output.ExitError signal (no envelope). + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth) + } + if exitErr.Detail != nil { + t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail) + } +} + func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ ProfileName: "default", diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 3b2ae09f..1e864fd7 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -60,7 +61,7 @@ func authLogoutRun(opts *LogoutOptions) error { } app.Users = []core.AppUser{} if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") return nil diff --git a/cmd/auth/qrcode.go b/cmd/auth/qrcode.go index b7467258..bc77d4f6 100644 --- a/cmd/auth/qrcode.go +++ b/cmd/auth/qrcode.go @@ -13,8 +13,8 @@ import ( "github.com/skip2/go-qrcode" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -63,7 +63,7 @@ For ASCII output, the result is printed to stdout with fixed size.`, // runQRCode executes the auth qrcode command. func runQRCode(opts *QRCodeOptions) error { if opts.URL == "" { - return output.Errorf(output.ExitValidation, "missing_url", "url is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url") } if opts.ASCII { @@ -75,20 +75,20 @@ func runQRCode(opts *QRCodeOptions) error { } if opts.Output == "" { - return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output") } if opts.Size < 32 { - return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size") } if opts.Size > 1024 { - return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size") } safePath, err := validate.SafeOutputPath(opts.Output) if err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil { @@ -108,7 +108,7 @@ func runQRCode(opts *QRCodeOptions) error { encoder := json.NewEncoder(out) encoder.SetEscapeHTML(false) if err := encoder.Encode(result); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err) } return nil @@ -118,12 +118,12 @@ func runQRCode(opts *QRCodeOptions) error { func generateImageQRCode(url string, size int, outputPath string) error { png, err := qrcode.Encode(url, qrcode.Medium, size) if err != nil { - return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err)) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err) } err = vfs.WriteFile(outputPath, png, 0644) if err != nil { - return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err)) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err) } return nil @@ -133,7 +133,7 @@ func generateImageQRCode(url string, size int, outputPath string) error { func generateASCIIQRCode(url string, w io.Writer) error { q, err := qrcode.New(url, qrcode.Medium) if err != nil { - return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err)) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err) } fmt.Fprint(w, q.ToSmallString(false)) diff --git a/cmd/auth/qrcode_test.go b/cmd/auth/qrcode_test.go index e6969e0f..a171026c 100644 --- a/cmd/auth/qrcode_test.go +++ b/cmd/auth/qrcode_test.go @@ -5,7 +5,6 @@ package auth import ( "encoding/json" - "errors" "io" "os" "path/filepath" @@ -171,29 +170,15 @@ func TestNewCmdAuthQRCode_HelpText(t *testing.T) { func TestRunQRCode_MissingURL(t *testing.T) { err := runQRCode(&QRCodeOptions{URL: ""}) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "missing_url" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } func TestRunQRCode_MissingOutput(t *testing.T) { err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256}) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "missing_output" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -203,15 +188,8 @@ func TestRunQRCode_InvalidSize(t *testing.T) { Size: 16, Output: "qr.png", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "invalid_size" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -221,15 +199,8 @@ func TestRunQRCode_SizeTooLarge(t *testing.T) { Size: 2048, Output: "qr.png", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail.Type != "invalid_size" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -239,12 +210,8 @@ func TestRunQRCode_UnsafeOutputPath(t *testing.T) { Size: 256, Output: "/etc/passwd", }) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -329,15 +296,8 @@ func TestGenerateImageQRCode_WriteError(t *testing.T) { if err == nil { t.Fatal("expected error writing to nonexistent directory") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitInternal { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal) - } - if exitErr.Detail.Type != "write_error" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal) } } @@ -358,11 +318,7 @@ func TestGenerateASCIIQRCode_EmptyString(t *testing.T) { if err == nil { t.Fatal("expected error for empty string") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Detail.Type != "encode_error" { - t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error") + if err == nil { + t.Fatal("expected error, got nil") } } diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go index c70898dd..4de47582 100644 --- a/cmd/auth/scopes.go +++ b/cmd/auth/scopes.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" ) @@ -50,11 +51,23 @@ func authScopesRun(opts *ScopesOptions) error { return err } fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n") - appInfo, err := getAppInfo(opts.Ctx, f, config.AppID) + appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID) if err != nil { - return output.ErrWithHint(output.ExitAPI, "permission", - fmt.Sprintf("failed to get app scope info: %v", err), - "ensure the app has enabled the application:application:self_manage scope.") + // Discriminate by error type so transport / parse failures are not + // reclassified as PermissionError(MissingScope) — re-auth does not + // fix network / 5xx / JSON parse errors and misclassifying them + // here would mislead agents into re-auth loops. + // - typed errors pass through unchanged + // - bare errors become InternalError(SubtypeSDKError) with Cause + // preserved so callers (errors.Is) can still see the underlying + // transport/parse failure. + // Genuine permission failures are surfaced from appInfo *content*, + // not from this transport-level error path. + if errs.IsTyped(err) { + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, + "failed to get app scope info: %v", err).WithCause(err) } if opts.Format == "pretty" { fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID) diff --git a/cmd/auth/scopes_test.go b/cmd/auth/scopes_test.go new file mode 100644 index 00000000..9ab748d6 --- /dev/null +++ b/cmd/auth/scopes_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun +// observes a fixed error from the dependency. t.Cleanup restores the prior +// value so tests cannot leak through the package-level seam. +func stubGetAppInfoErr(t *testing.T, errToReturn error) { + t.Helper() + prev := getAppInfoFn + getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { + return nil, errToReturn + } + t.Cleanup(func() { getAppInfoFn = prev }) +} + +// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive +// authScopesRun. Config has a non-empty AppID so we get past the config gate +// and reach the getAppInfoFn call. +func scopesTestFactory(t *testing.T) *ScopesOptions { + t.Helper() + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", + AppSecret: "test-secret", + Brand: core.BrandFeishu, + }) + return &ScopesOptions{ + Factory: f, + Ctx: context.Background(), + Format: "json", + } +} + +// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError +// surfaced by the dependency is not re-classified as PermissionError — +// re-auth does not fix DNS / transport failures and blanket-wrapping them +// would mislead agents into infinite re-auth loops. +func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) { + netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed") + stubGetAppInfoErr(t, netErr) + + err := authScopesRun(scopesTestFactory(t)) + if err == nil { + t.Fatal("expected error, got nil") + } + + var permErr *errs.PermissionError + if errors.As(err, &permErr) { + t.Errorf("network failure must not be classified as PermissionError; got %v", permErr) + } + var gotNet *errs.NetworkError + if !errors.As(err, &gotNet) { + t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err) + } + if gotNet != netErr { + t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr) + } +} + +// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission +// failures from the dependency also pass through — IsTyped() must not single +// out one category. +func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) { + permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing"). + WithMissingScopes("im:message") + stubGetAppInfoErr(t, permErr) + + err := authScopesRun(scopesTestFactory(t)) + if err == nil { + t.Fatal("expected error, got nil") + } + var got *errs.PermissionError + if !errors.As(err, &got) { + t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err) + } + if got != permErr { + t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr) + } +} + +// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch: +// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as +// *InternalError{SubtypeSDKError} with the original error preserved on +// Cause so errors.Is still walks to it. +func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) { + bareErr := fmt.Errorf("failed to parse response: unexpected EOF") + stubGetAppInfoErr(t, bareErr) + + err := authScopesRun(scopesTestFactory(t)) + if err == nil { + t.Fatal("expected error, got nil") + } + + var permErr *errs.PermissionError + if errors.As(err, &permErr) { + t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr) + } + + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *InternalError, got %T: %v", err, err) + } + if intErr.Subtype != errs.SubtypeSDKError { + t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError) + } + if !errors.Is(err, bareErr) { + t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it") + } +} diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 7bd64f36..2ec7843f 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/huh" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/i18n" @@ -181,7 +182,7 @@ type existingBinding struct { func finalizeSource(opts *BindOptions) (string, error) { explicit := strings.TrimSpace(strings.ToLower(opts.Source)) if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" { - return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source") } var detected string @@ -198,9 +199,10 @@ func finalizeSource(opts *BindOptions) (string, error) { // before any interactive prompts — running inside Hermes with // --source openclaw (or vice versa) is almost always a mistake. if explicit != "" && detected != "" && explicit != detected { - return "", output.ErrWithHint(output.ExitValidation, "bind", - fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected), - "remove --source to auto-detect, or run this command in the correct Agent context") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--source %q does not match detected Agent environment (%s)", explicit, detected). + WithHint("remove --source to auto-detect, or run this command in the correct Agent context"). + WithParam("--source") } // TUI: prompt for language before any downstream prompts. The source @@ -228,9 +230,10 @@ func finalizeSource(opts *BindOptions) (string, error) { if opts.IsTUI { return tuiSelectSource(opts) } - return "", output.ErrWithHint(output.ExitValidation, "bind", - "cannot determine Agent source: no --source flag and no Agent environment detected", - "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "cannot determine Agent source: no --source flag and no Agent environment detected"). + WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context"). + WithParam("--source") } // reconcileExistingBinding reads any existing config at configPath and decides @@ -335,8 +338,9 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error return nil } msg := getBindMsg(opts.UILang) - return output.ErrWithHint(output.ExitValidation, "bind", - msg.IdentityEscalationMessage, msg.IdentityEscalationHint) + return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, + "config bind --force", "%s", msg.IdentityEscalationMessage). + WithHint("%s", msg.IdentityEscalationHint) } // noticeUserDefaultRisk surfaces the user-identity impersonation risk on every @@ -407,17 +411,14 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}} if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { - return output.Errorf(output.ExitInternal, "bind", - "failed to create workspace directory: %v", err) + return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err) } data, err := json.MarshalIndent(multi, "", " ") if err != nil { - return output.Errorf(output.ExitInternal, "bind", - "failed to marshal config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err) } if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil { - return output.Errorf(output.ExitInternal, "bind", - "failed to write config %s: %v", configPath, err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err) } replaced := previousConfigBytes != nil @@ -628,7 +629,7 @@ func validateBindFlags(opts *BindOptions) error { switch opts.Identity { case "bot-only", "user-default": default: - return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity") } } lang, err := cmdutil.ParseLangFlag(opts.Lang) diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index b45b1ca1..0711e105 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -22,7 +22,9 @@ import ( // assertExitError checks the full structured error in one assertion. It // accepts both *output.ExitError (used by output.ErrWithHint) and the -// typed validation error — they normalize to the same wantDetail fields. +// typed errors (ValidationError, ConfigError) — they normalize to the same +// wantDetail fields. The wantDetail.Type is matched against the typed error's +// Category string ("validation", "config", etc.). func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) { t.Helper() if err == nil { @@ -52,7 +54,18 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er } return } - t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err) + var ce *errs.ConfigError + if errors.As(err, &ce) { + if got := output.ExitCodeOf(err); got != wantCode { + t.Errorf("exit code = %d, want %d", got, wantCode) + } + gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint} + if !reflect.DeepEqual(gotDetail, wantDetail) { + t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail) + } + return + } + t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err) } // assertEnvelope decodes stdout and checks it matches want exactly — every key @@ -370,7 +383,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) { // TestFactory has IsTerminal=false by default err := configBindRun(&BindOptions{Factory: f, Source: ""}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: "cannot determine Agent source: no --source flag and no Agent environment detected", Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context", }) @@ -409,7 +422,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: `--source "openclaw" does not match detected Agent environment (hermes)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", }) @@ -425,7 +438,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: `--source "hermes" does not match detected Agent environment (openclaw)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", }) @@ -553,8 +566,8 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "hermes", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "failed to read Hermes config: open " + envPath + ": no such file or directory", Hint: "verify Hermes is installed and configured at " + envPath, }) @@ -571,8 +584,8 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Hint: "verify OpenClaw is installed and configured", }) @@ -719,7 +732,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", + Type: "validation", Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", }) @@ -737,8 +750,8 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) configPath := filepath.Join(fakeHome, ".lark-channel", "config.json") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "lark-channel", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Hint: "verify lark-channel-bridge is installed and configured", }) @@ -757,8 +770,8 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "lark-channel", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "accounts.app.id missing in " + configPath, Hint: "run lark-channel-bridge's setup to populate the app credential", }) @@ -776,8 +789,8 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "lark-channel", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "accounts.app.secret is empty in " + configPath, Hint: "run lark-channel-bridge's setup to populate the app credential", }) @@ -1128,12 +1141,8 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) { if err == nil { t.Fatal("expected error for multi-account without --app-id, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError", err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } } @@ -1179,7 +1188,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) { // each accepted variant so every ErrDetail field (Type, Code, Message, // Hint, ConsoleURL, Detail, and any future addition) is still compared. base := output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: "multiple accounts in openclaw.json; pass --app-id ", } wantWorkFirst := base @@ -1187,20 +1196,17 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) { wantPersonalFirst := base wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)" - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err) } - if exitErr.Detail == nil { - t.Fatal("expected non-nil error detail") - } - if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) && - !reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) { + got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint} + if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) { t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v", - *exitErr.Detail, wantWorkFirst, wantPersonalFirst) + got, wantWorkFirst, wantPersonalFirst) } } @@ -1225,7 +1231,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"}) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_only_one", }) @@ -1357,11 +1363,19 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) { Identity: "user-default", }) msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "bind", - Message: msg.IdentityEscalationMessage, - Hint: msg.IdentityEscalationHint, - }) + var ce *errs.ConfirmationRequiredError + if !errors.As(err, &ce) { + t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err) + } + if ce.Risk != errs.RiskHighRiskWrite { + t.Errorf("Risk = %q, want %q", ce.Risk, errs.RiskHighRiskWrite) + } + if ce.Message != msg.IdentityEscalationMessage { + t.Errorf("Message mismatch:\ngot: %q\nwant: %q", ce.Message, msg.IdentityEscalationMessage) + } + if ce.Hint != msg.IdentityEscalationHint { + t.Errorf("Hint mismatch:\ngot: %q\nwant: %q", ce.Hint, msg.IdentityEscalationHint) + } // Config on disk must remain untouched — the gate runs before // commitBinding writes anything. @@ -1522,8 +1536,8 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "hermes", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "FEISHU_APP_ID not found in " + envPath, Hint: "run 'hermes setup' to configure Feishu credentials", }) @@ -1542,8 +1556,8 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "hermes", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "FEISHU_APP_SECRET not found in " + envPath, Hint: "run 'hermes setup' to configure Feishu credentials", }) @@ -1568,8 +1582,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "openclaw.json missing channels.feishu section", Hint: "configure Feishu in OpenClaw first", }) @@ -1596,8 +1610,8 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) { openclawPath := filepath.Join(openclawDir, "openclaw.json") f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "appSecret is empty for app cli_no_secret in " + openclawPath, Hint: "configure channels.feishu.appSecret in openclaw.json", }) @@ -1658,8 +1672,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "no Feishu app configured in openclaw.json", Hint: "configure channels.feishu.appId in openclaw.json", }) diff --git a/cmd/config/binder.go b/cmd/config/binder.go index c0608d6d..6b04086d 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -9,9 +9,9 @@ import ( "path/filepath" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/binding" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs" ) @@ -49,7 +49,7 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) { case "lark-channel": return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil default: - return nil, output.ErrValidation("unsupported source: %s", source) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source") } } @@ -85,11 +85,10 @@ func selectCandidate( // from ListCandidates itself and never reach here. switch src { case "openclaw": - return nil, output.ErrWithHint(output.ExitValidation, src, - "no Feishu app configured in openclaw.json", - "configure channels.feishu.appId in openclaw.json") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json"). + WithHint("configure channels.feishu.appId in openclaw.json") default: - return nil, output.ErrValidation("%s: no app configured", src) + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src) } } @@ -99,9 +98,9 @@ func selectCandidate( return &candidates[i], nil } } - return nil, output.ErrWithHint(output.ExitValidation, src, - fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase), - fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates))) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase). + WithHint("available app IDs:\n %s", formatCandidates(candidates)). + WithParam("--app-id") } if len(candidates) == 1 { @@ -112,9 +111,9 @@ func selectCandidate( return tuiPrompt(candidates) } - return nil, output.ErrWithHint(output.ExitValidation, src, - fmt.Sprintf("multiple accounts in %s; pass --app-id ", cfgBase), - fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates))) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id ", cfgBase). + WithHint("available app IDs:\n %s", formatCandidates(candidates)). + WithParam("--app-id") } // formatCandidates renders candidates as "AppID (Label)" lines for error hints. @@ -149,14 +148,13 @@ func (b *openclawBinder) ConfigPath() string { return b.path } func (b *openclawBinder) ListCandidates() ([]Candidate, error) { cfg, err := binding.ReadOpenClawConfig(b.path) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - fmt.Sprintf("cannot read %s: %v", b.path, err), - "verify OpenClaw is installed and configured") + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). + WithHint("verify OpenClaw is installed and configured"). + WithCause(err) } if cfg.Channels.Feishu == nil { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - "openclaw.json missing channels.feishu section", - "configure Feishu in OpenClaw first") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section"). + WithHint("configure Feishu in OpenClaw first") } raw := binding.ListCandidateApps(cfg.Channels.Feishu) @@ -172,8 +170,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) { func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { if b.cfg == nil { - return nil, output.Errorf(output.ExitInternal, "openclaw", - "internal: Build called before ListCandidates") + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } var selected *binding.CandidateApp @@ -184,26 +181,25 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { } } if selected == nil { - return nil, output.Errorf(output.ExitInternal, "openclaw", - "internal: appID %q not in candidates", appID) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID) } if selected.AppSecret.IsZero() { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path), - "configure channels.feishu.appSecret in openclaw.json") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path). + WithHint("configure channels.feishu.appSecret in openclaw.json") } secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "openclaw", - fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err), - fmt.Sprintf("check appSecret configuration in %s", b.path)) + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err). + WithHint("check appSecret configuration in %s", b.path). + WithCause(err) } stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { - return nil, output.Errorf(output.ExitInternal, "openclaw", - "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err) + return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). + WithHint("use file: reference in config to bypass keychain"). + WithCause(err) } return &core.AppConfig{ @@ -229,15 +225,14 @@ func (b *hermesBinder) ConfigPath() string { return b.path } func (b *hermesBinder) ListCandidates() ([]Candidate, error) { envMap, err := readDotenv(b.path) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "hermes", - fmt.Sprintf("failed to read Hermes config: %v", err), - fmt.Sprintf("verify Hermes is installed and configured at %s", b.path)) + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err). + WithHint("verify Hermes is installed and configured at %s", b.path). + WithCause(err) } appID := envMap["FEISHU_APP_ID"] if appID == "" { - return nil, output.ErrWithHint(output.ExitValidation, "hermes", - fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path), - "run 'hermes setup' to configure Feishu credentials") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path). + WithHint("run 'hermes setup' to configure Feishu credentials") } b.envMap = envMap return []Candidate{{AppID: appID, Label: "default"}}, nil @@ -245,24 +240,22 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) { func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { if b.envMap == nil { - return nil, output.Errorf(output.ExitInternal, "hermes", - "internal: Build called before ListCandidates") + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } if b.envMap["FEISHU_APP_ID"] != appID { - return nil, output.Errorf(output.ExitInternal, "hermes", - "internal: appID %q does not match env", appID) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID) } appSecret := b.envMap["FEISHU_APP_SECRET"] if appSecret == "" { - return nil, output.ErrWithHint(output.ExitValidation, "hermes", - fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path), - "run 'hermes setup' to configure Feishu credentials") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path). + WithHint("run 'hermes setup' to configure Feishu credentials") } stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain) if err != nil { - return nil, output.Errorf(output.ExitInternal, "hermes", - "keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err) + return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). + WithHint("use file: reference in config to bypass keychain"). + WithCause(err) } return &core.AppConfig{ @@ -290,14 +283,13 @@ func (b *larkChannelBinder) ConfigPath() string { return b.path } func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { cfg, err := binding.ReadLarkChannelConfig(b.path) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("cannot read %s: %v", b.path, err), - "verify lark-channel-bridge is installed and configured") + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err). + WithHint("verify lark-channel-bridge is installed and configured"). + WithCause(err) } if cfg.Accounts.App.ID == "" { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("accounts.app.id missing in %s", b.path), - "run lark-channel-bridge's setup to populate the app credential") + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path). + WithHint("run lark-channel-bridge's setup to populate the app credential") } b.cfg = cfg return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil @@ -305,32 +297,30 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) { func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { if b.cfg == nil { - return nil, output.Errorf(output.ExitInternal, "lark-channel", - "internal: Build called before ListCandidates") + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } if b.cfg.Accounts.App.ID != appID { - return nil, output.Errorf(output.ExitInternal, "lark-channel", - "internal: appID %q does not match config", appID) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID) } if b.cfg.Accounts.App.Secret.IsZero() { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("accounts.app.secret is empty in %s", b.path), - "run lark-channel-bridge's setup to populate the app credential") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path). + WithHint("run lark-channel-bridge's setup to populate the app credential") } // Resolve through the same SecretInput pipeline openclaw uses, so // bridge configs can use ${VAR} / env / file / exec just like openclaw. secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv) if err != nil { - return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", - fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err), - fmt.Sprintf("check appSecret configuration in %s", b.path)) + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err). + WithHint("check appSecret configuration in %s", b.path). + WithCause(err) } stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { - return nil, output.Errorf(output.ExitInternal, "lark-channel", - "keychain unavailable: %v", err) + return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). + WithHint("use file: reference in config to bypass keychain"). + WithCause(err) } return &core.AppConfig{ diff --git a/cmd/config/binder_test.go b/cmd/config/binder_test.go index c86dc35b..6f1df131 100644 --- a/cmd/config/binder_test.go +++ b/cmd/config/binder_test.go @@ -51,8 +51,8 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) { func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) { b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "no Feishu app configured in openclaw.json", Hint: "configure channels.feishu.appId in openclaw.json", }) @@ -64,8 +64,8 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) { // even before it has a bespoke error message. b := &fakeBinder{name: "hermes", path: "/tmp/.env"} _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "validation", + assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + Type: "config", Message: "hermes: no app configured", }) } @@ -101,7 +101,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) { } _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", }) @@ -118,7 +118,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) { } _, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t)) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: "multiple accounts in openclaw.json; pass --app-id ", Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", }) @@ -153,7 +153,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) { candidates := []Candidate{{AppID: "cli_only"}} _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) assertExitError(t, err, output.ExitValidation, output.ErrDetail{ - Type: "openclaw", + Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_only", }) diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 632414cd..04645c4e 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -126,15 +126,11 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) { t.Fatal("expected error") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError", err) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth) } - if exitErr.Code != output.ExitValidation { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" { - t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail) + if !strings.Contains(err.Error(), "no active profile") { + t.Fatalf("error = %v, want to contain 'no active profile'", err) } } @@ -469,15 +465,8 @@ func TestConfigBlockedByExternalProvider(t *testing.T) { if matched != nil && matched != cmd && !matched.SilenceUsage { t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" { - t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider") + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation { + t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation) } }) } diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go index a5078c1e..f1d5de4e 100644 --- a/cmd/config/default_as.go +++ b/cmd/config/default_as.go @@ -6,9 +6,9 @@ package config import ( "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -41,12 +41,12 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { value := args[0] if value != "user" && value != "bot" && value != "auto" { - return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value) } app.DefaultAs = core.Identity(value) if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value) return nil diff --git a/cmd/config/init.go b/cmd/config/init.go index b505ce8c..caef603e 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/huh" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -245,9 +246,29 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int { return -1 } +// wrapUpdateExistingProfileErr classifies the error returned by +// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError +// for blank-input) pass through unchanged so their exit code semantics +// survive; legacy *output.ExitError also passes through; everything else +// (filesystem, keychain, etc.) is wrapped as InternalError. +func wrapUpdateExistingProfileErr(err error) error { + if err == nil { + return nil + } + if errs.IsTyped(err) { + return err + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err) +} + func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error { if existing == nil { - return output.ErrValidation("App Secret cannot be empty for new configuration") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration"). + WithParam("--app-secret") } var app *core.AppConfig @@ -255,17 +276,20 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa if idx := findProfileIndexByName(existing, profileName); idx >= 0 { app = &existing.Apps[idx] } else { - return output.ErrValidation("App Secret cannot be empty for new profile") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile"). + WithParam("--app-secret") } } else { app = existing.CurrentAppConfig("") if app == nil { - return output.ErrValidation("App Secret cannot be empty for new configuration") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration"). + WithParam("--app-secret") } } if app.AppId != appID { - return output.ErrValidation("App Secret cannot be empty when changing App ID") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID"). + WithParam("--app-secret") } app.AppId = appID @@ -282,13 +306,13 @@ func configInitRun(opts *ConfigInitOptions) error { scanner := bufio.NewScanner(f.IOStreams.In) if !scanner.Scan() { if err := scanner.Err(); err != nil { - return output.ErrValidation("failed to read secret from stdin: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err) } - return output.ErrValidation("stdin is empty, expected app secret") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret") } opts.appSecret = strings.TrimSpace(scanner.Text()) if opts.appSecret == "" { - return output.ErrValidation("app secret read from stdin is empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty") } } @@ -300,7 +324,7 @@ func configInitRun(opts *ConfigInitOptions) error { // Validate --profile name if set if opts.ProfileName != "" { if err := core.ValidateProfileName(opts.ProfileName); err != nil { - return output.ErrValidation("%v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err) } } @@ -309,10 +333,10 @@ func configInitRun(opts *ConfigInitOptions) error { brand := parseBrand(opts.Brand) secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) @@ -344,15 +368,15 @@ func configInitRun(opts *ConfigInitOptions) error { return err } if result == nil { - return output.ErrValidation("app creation returned no result") + return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result") } existing, _ := core.LoadMultiAppConfig() secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) @@ -366,7 +390,8 @@ func configInitRun(opts *ConfigInitOptions) error { return err } if result == nil { - return output.ErrValidation("App ID and App Secret cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") } existing, _ := core.LoadMultiAppConfig() @@ -375,23 +400,19 @@ func configInitRun(opts *ConfigInitOptions) error { // New secret provided (either from "create" or "existing" with input) secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } } else if result.Mode == "existing" && result.AppID != "" { // Existing app with unchanged secret — update app ID and brand only - if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil { - // Deprecated: legacy *output.ExitError passthrough; removed after typed migration. - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - return err - } - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil { + return err } } else { - return output.ErrValidation("App ID and App Secret cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") } if result.Mode == "existing" { @@ -403,7 +424,7 @@ func configInitRun(opts *ConfigInitOptions) error { // Non-terminal: cannot run interactive mode, guide user to --new if !f.IOStreams.IsTerminal { - return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") } // Mode 5: Legacy interactive (readline fallback) @@ -431,7 +452,7 @@ func configInitRun(opts *ConfigInitOptions) error { } appIdInput, err := readLine(prompt) if err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) } prompt = "App Secret" @@ -440,7 +461,7 @@ func configInitRun(opts *ConfigInitOptions) error { } appSecretInput, err := readLine(prompt) if err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) } prompt = "Brand (lark/feishu)" @@ -451,7 +472,7 @@ func configInitRun(opts *ConfigInitOptions) error { } brandInput, err := readLine(prompt) if err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err) } resolvedAppId := appIdInput @@ -473,15 +494,16 @@ func configInitRun(opts *ConfigInitOptions) error { } if resolvedAppId == "" || resolvedSecret.IsZero() { - return output.ErrValidation("App ID and App Secret cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") } storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index 0a511cd0..a32dee53 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -12,6 +12,7 @@ import ( "github.com/larksuite/cli/internal/build" qrcode "github.com/skip2/go-qrcode" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -125,8 +126,16 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er }, nil } - if appID == "" || appSecret == "" { - return nil, output.ErrValidation("App ID and App Secret cannot be empty") + switch { + case appID == "" && appSecret == "": + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty"). + WithParam("--app-id") + case appID == "": + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty"). + WithParam("--app-id") + case appSecret == "": + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty"). + WithParam("--app-secret") } return &configInitResult{ @@ -171,7 +180,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor httpClient := &http.Client{} authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) if err != nil { - return nil, output.ErrAuth("app registration failed: %v", err) + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err) } // Step 2: Build and display verification URL + QR code @@ -199,7 +208,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor } result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) if err != nil { - return nil, output.ErrAuth("%v", err) + return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err) } // Step 4: Handle Lark brand special case @@ -208,12 +217,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor // fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant) result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) if err != nil { - return nil, output.ErrAuth("lark endpoint retry failed: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err) } } if result.ClientID == "" || result.ClientSecret == "" { - return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret") } // Determine final brand from response diff --git a/cmd/config/init_test.go b/cmd/config/init_test.go new file mode 100644 index 00000000..7df9bbee --- /dev/null +++ b/cmd/config/init_test.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "errors" + "fmt" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each +// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2: +// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials, +// not for missing user input. + +func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) { + err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) { + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app-default", + AppSecret: core.PlainSecret("secret-default"), + Brand: core.BrandFeishu, + }}, + } + err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) { + existing := &core.MultiAppConfig{ + CurrentApp: "missing", + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app-default", + AppSecret: core.PlainSecret("secret-default"), + Brand: core.BrandFeishu, + }}, + } + err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) { + existing := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + Name: "default", + AppId: "app-default", + AppSecret: core.PlainSecret("secret-default"), + Brand: core.BrandFeishu, + }}, + } + err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en") + assertValidationParam(t, err, "--app-secret") +} + +// wrapUpdateExistingProfileErr is the caller-side classifier for the error +// returned by updateExistingProfileWithoutSecret. It must preserve typed-error +// exit semantics (regression: typed ValidationError was being downgraded to +// InternalError by the legacy *output.ExitError-only passthrough). + +func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) { + if got := wrapUpdateExistingProfileErr(nil); got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) { + in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile"). + WithParam("--app-secret") + got := wrapUpdateExistingProfileErr(in) + assertValidationParam(t, got, "--app-secret") + // Exit code must remain ExitValidation (2), not ExitInternal (5). + if code := output.ExitCodeOf(got); code != output.ExitValidation { + t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + // Must NOT be wrapped as *InternalError. + var intErr *errs.InternalError + if errors.As(got, &intErr) { + t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got) + } +} + +func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) { + in := &output.ExitError{Code: 7, Err: errors.New("legacy")} + got := wrapUpdateExistingProfileErr(in) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got) + } + if exitErr.Code != 7 { + t.Errorf("Code = %d, want 7", exitErr.Code) + } +} + +func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) { + in := fmt.Errorf("disk full") + got := wrapUpdateExistingProfileErr(in) + var intErr *errs.InternalError + if !errors.As(got, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T: %v", got, got) + } + if intErr.Subtype != errs.SubtypeSDKError { + t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError) + } +} + +// assertValidationParam asserts err is *ValidationError with the given Param. +func assertValidationParam(t *testing.T, err error, wantParam string) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if valErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) + } + if valErr.Param != wantParam { + t.Errorf("Param = %q, want %q", valErr.Param, wantParam) + } +} diff --git a/cmd/config/keychain_downgrade.go b/cmd/config/keychain_downgrade.go index 58c179d2..cf355185 100644 --- a/cmd/config/keychain_downgrade.go +++ b/cmd/config/keychain_downgrade.go @@ -8,6 +8,7 @@ package config import ( "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/output" @@ -53,12 +54,10 @@ func configKeychainDowngradeRun(f *cmdutil.Factory) error { result, err := keychain.DowngradeMasterKeyToFile(service) if err != nil { - return output.ErrWithHint( - output.ExitAPI, - "config", - fmt.Sprintf("keychain downgrade failed: %v", err), - "This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.", - ) + return errs.NewInternalError(errs.SubtypeSDKError, + "keychain downgrade failed: %v", err). + WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design."). + WithCause(err) } switch result { diff --git a/cmd/config/keychain_downgrade_other.go b/cmd/config/keychain_downgrade_other.go index 6255aee4..afa1563c 100644 --- a/cmd/config/keychain_downgrade_other.go +++ b/cmd/config/keychain_downgrade_other.go @@ -6,8 +6,8 @@ package config import ( + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -21,7 +21,7 @@ func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command { Short: "Downgrade keychain storage to a local file (macOS only)", Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`, RunE: func(cmd *cobra.Command, args []string) error { - return output.ErrValidation("keychain-downgrade is only supported on macOS") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS") }, } return cmd diff --git a/cmd/config/remove.go b/cmd/config/remove.go index 324f7e58..74dd0e84 100644 --- a/cmd/config/remove.go +++ b/cmd/config/remove.go @@ -6,6 +6,7 @@ package config import ( "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -42,14 +43,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error { config, err := core.LoadMultiAppConfig() if err != nil || config == nil || len(config.Apps) == 0 { - return output.ErrValidation("not configured yet") + return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet") } // Save empty config first. If this fails, keep secrets and tokens intact so the // existing config can still be retried instead of ending up half-removed. empty := &core.MultiAppConfig{Apps: []core.AppConfig{}} if err := core.SaveMultiAppConfig(empty); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } // Clean up keychain entries for all apps after config is cleared. diff --git a/cmd/config/show.go b/cmd/config/show.go index 1f0f12ff..5526f025 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -9,6 +9,7 @@ import ( "os" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -47,14 +48,14 @@ func configShowRun(opts *ConfigShowOptions) error { if errors.Is(err, os.ErrNotExist) { return core.NotConfiguredError() } - return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err) + return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err) } if config == nil || len(config.Apps) == 0 { return core.NotConfiguredError() } app := config.CurrentAppConfig(f.Invocation.Profile) if app == nil { - return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list") + return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list") } users := "(no logged-in users)" if len(app.Users) > 0 { diff --git a/cmd/config/strict_mode.go b/cmd/config/strict_mode.go index 6bac8242..46610585 100644 --- a/cmd/config/strict_mode.go +++ b/cmd/config/strict_mode.go @@ -7,9 +7,9 @@ import ( "context" "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -73,14 +73,14 @@ explicit user confirmation — never run on your own initiative.`, func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error { if global { - return output.ErrValidation("--reset cannot be used with --global") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset") } if len(args) > 0 { - return output.ErrValidation("--reset cannot be used with a value argument") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset") } app.StrictMode = nil if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)") return nil @@ -104,7 +104,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App switch mode { case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff: default: - return output.ErrValidation("invalid value %q, valid values: bot | user | off", value) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value) } // Capture the old mode at the SAME scope being changed, so we can warn @@ -144,7 +144,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App } if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) { diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 8f6d8532..2851a1b4 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -23,12 +23,8 @@ import ( // applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a // "current command requires scope(s): X, Y" hint when the underlying error is // a need_user_authorization signal AND the current command declares scopes -// locally (via shortcut registration or service-method metadata). -// -// Stage-1: this typed path is dormant — no production code returns a typed -// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug -// in without re-architecting. The active stage-1 path is -// enrichMissingScopeError below, which operates on legacy *output.ExitError. +// locally (via shortcut registration or service-method metadata). Existing +// Hint text is preserved; scopes are appended on a new line. func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) { if err == nil || f == nil { return @@ -55,12 +51,10 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) { // enrichMissingScopeError appends a "current command requires scope(s): X" // hint to a legacy *output.ExitError when the underlying error carries the // need_user_authorization marker AND the current command declares scopes -// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy -// envelope path until per-domain stage-2 typed migration. +// locally. // -// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface. -// Stage-2 typed migration will lift this into AuthenticationError.Hint on -// the typed envelope via applyNeedAuthorizationHint and remove this helper. +// Deprecated: enrichment for the legacy envelope; the typed path is +// applyNeedAuthorizationHint above. func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) { if exitErr == nil || exitErr.Detail == nil { return @@ -155,47 +149,7 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s if methodMap == nil { return nil } - return declaredScopesForMethod(methodMap, identity) -} - -// declaredScopesForMethod returns all requiredScopes when present; otherwise it -// resolves the single recommended scope from the method's scopes list. -func declaredScopesForMethod(method map[string]interface{}, identity string) []string { - if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { - return interfaceStrings(requiredRaw) - } - - rawScopes, _ := method["scopes"].([]interface{}) - if len(rawScopes) == 0 { - return nil - } - recommended := registry.SelectRecommendedScope(rawScopes, identity) - if recommended == "" { - for _, raw := range rawScopes { - if scope, ok := raw.(string); ok && scope != "" { - recommended = scope - break - } - } - } - if recommended == "" { - return nil - } - return []string{recommended} -} - -// interfaceStrings converts a []interface{} containing strings into a compact -// []string, skipping empty or non-string values. -func interfaceStrings(values []interface{}) []string { - scopes := make([]string, 0, len(values)) - for _, value := range values { - scope, ok := value.(string) - if !ok || scope == "" { - continue - } - scopes = append(scopes, scope) - } - return scopes + return registry.DeclaredScopesForMethod(methodMap, identity) } // shortcutSupportsIdentity reports whether a shortcut supports the requested diff --git a/cmd/root.go b/cmd/root.go index 5e6dd5dd..e86ff069 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,23 +4,22 @@ package cmd import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" "os" "sort" - "strconv" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" + internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" @@ -201,43 +200,59 @@ func configureFlagCompletions(args []string) { // and returns the process exit code. // // Dispatch order: -// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope -// (type=auth_error, string code, retryable, challenge_url) and exit 1. -// Carve-out from the typed taxonomy — wire migration deferred to a later PR. -// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError): -// render via the typed envelope writer, which lifts extension fields -// (missing_scopes, console_url, ...) to the top level. Routed by +// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError) +// are promoted via errcompat to their typed errs/ counterparts, with the +// original preserved in the Cause chain. +// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError, +// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the +// typed envelope writer, which lifts extension fields (missing_scopes, +// console_url, challenge_url, ...) to the top level. Routed by // errs.CategoryOf via ExitCodeOf. -// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them -// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps -// this path so existing wire shapes are preserved byte-for-byte until -// per-domain typed migration in stage 2+. +// 3. Legacy *output.ExitError: asExitError adapts it to the legacy +// envelope, written via WriteErrorEnvelope. // 4. Cobra errors (required flags, unknown commands, etc.): plain text. func handleRootError(f *cmdutil.Factory, err error) int { errOut := f.IOStreams.ErrOut - // SecurityPolicyError keeps the legacy custom envelope (string codes, - // challenge_url, retryable) and exit code 1 — its wire shape predates the - // typed taxonomy and downstream OAuth/policy consumers depend on it. - // The taxonomy migration for this category is deferred to a later PR. - var spErr *errs.SecurityPolicyError - if errors.As(err, &spErr) { - writeSecurityPolicyError(errOut, spErr) - return 1 + // Promote legacy error shapes into typed errs/ before envelope marshal. + // NeedAuthorizationError check is first because it is the more specific + // shape; *core.ConfigError check follows. errors.As preserves the original + // in the Cause chain, so external errors.As(&core.ConfigError{}) consumers + // (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match. + // + // Outer-typed short-circuit: if err is already a typed *errs.* error, + // skip PromoteXxxError so the producer's Subtype / Hint / extension + // fields are not overwritten by a coarser promoted shape derived from a + // legacy error buried in its Cause chain. Promotion is only for legacy + // untyped entry points. + if !isOuterTypedError(err) { + var needAuthErr *internalauth.NeedAuthorizationError + if errors.As(err, &needAuthErr) { + err = errcompat.PromoteAuthError(needAuthErr) + } else { + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + err = errcompat.PromoteConfigError(cfgErr) + } + } } - // *core.ConfigError flows raw to the legacy envelope path in stage 1 - // (asExitError → output.ErrWithHint). Typed migration via - // errcompat.PromoteConfigError happens in stage 2+. - // When the typed error is a need_user_authorization signal, fold in the // current command's declared scopes as a Hint so the user/AI sees the // concrete scope(s) to re-auth with. The hint is computed on the fly from // local shortcut/service metadata — it never depends on server state. applyNeedAuthorizationHint(f, err) + // Staged dispatch: capture the typed exit code BEFORE attempting the + // envelope write. WriteTypedErrorEnvelope is best-effort on the wire + // (partial-write still returns true) so the exit code we read here is + // preserved even if stderr is torn — torn stderr must not downgrade + // typed exits 3/4/6/10 to the legacy "Error:" path with exit 1. + // WriteTypedErrorEnvelope still returns false when err carries no + // Problem; in that case we fall through to the legacy bridge below. + typedExit := output.ExitCodeOf(err) if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) { - return output.ExitCodeOf(err) + return typedExit } if exitErr := asExitError(err); exitErr != nil { @@ -256,52 +271,19 @@ func handleRootError(f *cmdutil.Factory, err error) int { return 1 } -// writeSecurityPolicyError writes the security-policy-specific JSON envelope. -// This wire format intentionally differs from the typed envelope writer: it -// uses string codes ("challenge_required"/"access_denied"), a "auth_error" -// type literal, and a top-level "retryable" field — the shape OAuth/policy -// consumers have been parsing since before the typed taxonomy existed. -func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) { - var codeStr string - switch spErr.Subtype { - case errs.SubtypeChallengeRequired: - codeStr = "challenge_required" - case errs.SubtypeAccessDenied: - codeStr = "access_denied" - default: - codeStr = strconv.Itoa(spErr.Code) - } - - errData := map[string]interface{}{ - "type": "auth_error", - "code": codeStr, - "message": spErr.Message, - "retryable": false, - } - if spErr.ChallengeURL != "" { - errData["challenge_url"] = spErr.ChallengeURL - } - if spErr.Hint != "" { - errData["hint"] = spErr.Hint - } - - env := map[string]interface{}{"ok": false, "error": errData} - - buffer := &bytes.Buffer{} - encoder := json.NewEncoder(buffer) - encoder.SetEscapeHTML(false) - encoder.SetIndent("", " ") - if encErr := encoder.Encode(env); encErr != nil { - fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`) - return - } - fmt.Fprint(w, buffer.String()) +// isOuterTypedError returns true if err is a typed *errs.* error AT THE +// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError +// to gate PromoteXxxError so a producer's outer typed envelope is never +// overwritten by a coarser shape derived from its legacy Cause. +func isOuterTypedError(err error) bool { + _, ok := err.(errs.TypedError) + return ok } // asExitError converts known structured error types to *output.ExitError. // Returns nil for unrecognized errors (e.g. cobra flag errors). // -// Deprecated: legacy *output.ExitError bridge; removed after typed migration. +// Deprecated: legacy *output.ExitError bridge. func asExitError(err error) *output.ExitError { var cfgErr *core.ConfigError if errors.As(err, &cfgErr) { @@ -417,65 +399,55 @@ func installTipsHelpFunc(root *cobra.Command) { }) } -// enrichPermissionError adds console_url and improves the hint for legacy -// *output.ExitError permission errors. Differentiates between: -// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the scope -// - LarkErrUserScopeInsufficient (99991679) / LarkErrUserNotAuthorized: -// user has not authorized the scope → hint to auth login -// - default: other permission errors → console + auth-login fallback +// enrichPermissionError rewrites the legacy *output.ExitError envelope so its +// Message + Hint match the per-subtype canonical text produced by the typed +// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint). +// This guarantees a caller observing the wire envelope cannot tell whether +// the error reached the dispatcher via the legacy *ExitError bridge or via +// the typed *errs.PermissionError fast path. // -// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope. -// Stage-2 typed migration will lift this into PermissionError.MissingScopes -// + ConsoleURL on the typed envelope and remove this helper. +// Deprecated: legacy *output.ExitError enrichment; typed PermissionError +// values produced by errclass.BuildAPIError already carry MissingScopes + +// ConsoleURL directly. func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { - if exitErr.Detail == nil || exitErr.Detail.Type != "permission" { + if exitErr.Detail == nil { return } - // Extract required scopes from API error detail (shared helper) - scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail) - if len(scopes) == 0 { + // Only the legacy permission-class envelope types route here. "app_status" + // covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission" + // covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027). + if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" { return } + larkCode := exitErr.Detail.Code + meta, ok := errclass.LookupCodeMeta(larkCode) + if !ok || meta.Category != errs.CategoryAuthorization { + return + } + + // Extract required scopes from API error detail (shared helper). May be + // empty for app-status codes — canonical message + hint still apply. + missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail) + cfg, err := f.Config() if err != nil { return } - // Select the recommended (least-privilege) scope - recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant") - - // Build admin console URL with the recommended scope - consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended) + // Reuse the same console URL builder as the typed path so both wire + // envelopes carry identical console_url values for the same input. + consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing) // Clear raw API detail — useful info is now in message/hint/console_url. exitErr.Detail.Detail = nil - isBot := f.ResolvedIdentity.IsBot() - larkCode := exitErr.Detail.Code - switch larkCode { - case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized: - exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode) - if isBot { - exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" - } else { - exitErr.Detail.Hint = fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) - } - exitErr.Detail.ConsoleURL = consoleURL - - case output.LarkErrAppScopeNotEnabled: - exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode) - exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" - exitErr.Detail.ConsoleURL = consoleURL - - default: - exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode) - if isBot { - exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" - } else { - exitErr.Detail.Hint = fmt.Sprintf( - "enable scope in console (see console_url), or run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) - } - exitErr.Detail.ConsoleURL = consoleURL + identity := string(f.ResolvedIdentity) + if identity == "" { + identity = "user" } + + exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message) + exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL) + exitErr.Detail.ConsoleURL = consoleURL } diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index b7bdb1bb..104ae4e8 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -281,7 +281,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn OK: false, Identity: "bot", Error: &output.ErrDetail{ - Type: "command_denied", + Type: "validation", Message: `strict mode is "user", only user-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -300,7 +300,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "command_denied", + Type: "validation", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -345,7 +345,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "command_denied", + Type: "validation", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, diff --git a/cmd/root_test.go b/cmd/root_test.go index f2d437e9..3ab78ceb 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "strings" "testing" @@ -20,6 +21,7 @@ import ( internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" ) @@ -137,81 +139,96 @@ func TestIsCompletionCommand(t *testing.T) { // TestPromoteConfigError_* lives with the implementation in // internal/errcompat/promote_test.go. -// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out -// for *errs.SecurityPolicyError: it does NOT go through the typed envelope -// writer. Downstream OAuth/policy consumers parse a wire format that -// predates the typed taxonomy and depend on: -// - error.type == "auth_error" (not the Category literal "policy") -// - error.code is a string ("challenge_required" / "access_denied"), not a number -// - error.retryable is present at the top of the error object -// - exit code 1 (not ExitContentSafety 6) -// -// Migration of this category to the typed envelope is deferred to a later PR. -func TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope(t *testing.T) { +// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that +// *errs.SecurityPolicyError flows through the canonical typed envelope +// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype, +// top-level identity, exit code 6 — after the dispatcher carve-out is removed. +func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - cases := []struct { - name string - subtype errs.Subtype - code int - wantCode string - }{ - {"challenge_required", errs.SubtypeChallengeRequired, 21000, "challenge_required"}, - {"access_denied", errs.SubtypeAccessDenied, 21001, "access_denied"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, nil) - errOut := &bytes.Buffer{} - f.IOStreams.ErrOut = errOut + t.Run("21000 challenge_required", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut - spErr := &errs.SecurityPolicyError{ - Problem: errs.Problem{ - Category: errs.CategoryPolicy, - Subtype: tc.subtype, - Code: tc.code, - Message: "blocked by access policy", - Hint: "complete challenge in your browser", - }, - ChallengeURL: "https://example.com/challenge", - } + spErr := &errs.SecurityPolicyError{ + Problem: errs.Problem{ + Category: errs.CategoryPolicy, + Subtype: errs.SubtypeChallengeRequired, + Code: 21000, + Message: "blocked by access policy", + Hint: "complete challenge in your browser", + }, + ChallengeURL: "https://example.com/challenge", + } - gotExit := handleRootError(f, spErr) - if gotExit != 1 { - t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit) - } + gotExit := handleRootError(f, spErr) + if gotExit != int(output.ExitContentSafety) { + t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety) + } - var env map[string]any - if err := json.Unmarshal(errOut.Bytes(), &env); err != nil { - t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String()) - } - errObj, ok := env["error"].(map[string]any) - if !ok { - t.Fatalf("envelope missing top-level error object: %s", errOut.String()) - } - if got := errObj["type"]; got != "auth_error" { - t.Errorf("error.type = %v, want %q", got, "auth_error") - } - if got := errObj["code"]; got != tc.wantCode { - t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode) - } - if got, ok := errObj["retryable"].(bool); !ok || got { - t.Errorf("error.retryable = %v (%T), want false (bool)", errObj["retryable"], errObj["retryable"]) - } - if got := errObj["challenge_url"]; got != "https://example.com/challenge" { - t.Errorf("error.challenge_url = %v, want challenge url", got) - } - if got := errObj["hint"]; got != "complete challenge in your browser" { - t.Errorf("error.hint = %v, want hint message", got) - } - // And the typed-only fields must NOT appear on this envelope. - for _, leaked := range []string{"subtype", "missing_scopes", "console_url"} { - if _, exists := errObj[leaked]; exists { - t.Errorf("error.%s leaked into legacy security envelope: %v", leaked, errObj[leaked]) - } - } - }) - } + var env map[string]any + if err := json.Unmarshal(errOut.Bytes(), &env); err != nil { + t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String()) + } + errObj, ok := env["error"].(map[string]any) + if !ok { + t.Fatalf("envelope missing top-level error object: %s", errOut.String()) + } + if got := errObj["type"]; got != "policy" { + t.Errorf("error.type = %v, want %q", got, "policy") + } + if got := errObj["subtype"]; got != "challenge_required" { + t.Errorf("error.subtype = %v, want %q", got, "challenge_required") + } + if got, ok := errObj["code"].(float64); !ok || int(got) != 21000 { + t.Errorf("error.code = %v (%T), want 21000 (number)", errObj["code"], errObj["code"]) + } + if got := errObj["challenge_url"]; got != "https://example.com/challenge" { + t.Errorf("error.challenge_url = %v, want challenge url", got) + } + if got := errObj["hint"]; got != "complete challenge in your browser" { + t.Errorf("error.hint = %v, want hint message", got) + } + if _, exists := errObj["retryable"]; exists { + t.Errorf("error.retryable leaked into canonical envelope: %v", errObj["retryable"]) + } + }) + + t.Run("21001 access_denied", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + spErr := &errs.SecurityPolicyError{ + Problem: errs.Problem{ + Category: errs.CategoryPolicy, + Subtype: errs.SubtypeAccessDenied, + Code: 21001, + Message: "access denied", + }, + } + + gotExit := handleRootError(f, spErr) + if gotExit != int(output.ExitContentSafety) { + t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety) + } + + var env map[string]any + if err := json.Unmarshal(errOut.Bytes(), &env); err != nil { + t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String()) + } + errObj := env["error"].(map[string]any) + if got := errObj["type"]; got != "policy" { + t.Errorf("error.type = %v, want %q", got, "policy") + } + if got := errObj["subtype"]; got != "access_denied" { + t.Errorf("error.subtype = %v, want %q", got, "access_denied") + } + if got, ok := errObj["code"].(float64); !ok || int(got) != 21001 { + t.Errorf("error.code = %v, want 21001 (number)", errObj["code"]) + } + }) } // newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message @@ -230,6 +247,77 @@ func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError { } } +// failingWriter writes up to limit bytes then returns io.ErrShortWrite on +// the write that would push past the limit. Used to simulate a stderr that +// dies mid-envelope. +type failingWriter struct { + limit int + n int +} + +func (f *failingWriter) Write(p []byte) (int, error) { + if f.n+len(p) > f.limit { + canWrite := f.limit - f.n + if canWrite < 0 { + canWrite = 0 + } + f.n += canWrite + return canWrite, io.ErrShortWrite + } + f.n += len(p) + return len(p), nil +} + +// TestHandleRootError_PartialWritePreservesExitCode pins that when the +// stderr write fails mid-envelope, handleRootError still returns the typed +// exit code (ExitAuth=3 for AuthenticationError), not fall through to the +// plain "Error:" path with exit 1. ExitCodeOf is computed from the typed +// err BEFORE the envelope write so the exit code is preserved even when +// the consumer's stderr pipe dies. +func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + w := &failingWriter{limit: 20} + f.IOStreams.ErrOut = w + + err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired") + exit := handleRootError(f, err) + if exit != int(output.ExitAuth) { + t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth)) + } +} + +// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed +// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its +// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so +// would replace the producer's TokenExpired subtype + custom hint with the +// promoted shape's TokenMissing. +func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + innerLegacy := &internalauth.NeedAuthorizationError{UserOpenId: "u_123"} + outer := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired"). + WithHint("custom producer hint"). + WithCause(innerLegacy) + + exit := handleRootError(f, outer) + if exit != int(output.ExitAuth) { + t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth)) + } + got := errOut.String() + if !strings.Contains(got, `"subtype": "token_expired"`) { + t.Errorf("envelope lost producer Subtype TokenExpired; got %s", got) + } + if !strings.Contains(got, "custom producer hint") { + t.Errorf("envelope lost producer Hint; got %s", got) + } +} + // TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins // that a typed AuthenticationError carrying the need_user_authorization marker gets a // declared-scopes Hint appended when the current command is a registered @@ -357,3 +445,136 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { t.Errorf("expected appended hint %q, got %q", want, authErr.Hint) } } + +// TestEnrichPermissionError_CanonicalConvergence pins that the legacy +// *output.ExitError dispatch path produces the same canonical Message + Hint +// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths +// share errclass.CanonicalPermissionMessage / errclass.PermissionHint / +// errclass.ConsoleURL — so a wire consumer cannot tell which path produced +// the envelope. +func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cases := []struct { + name string + larkCode int + legacyErrType string + wantMsgSubstrs []string + wantHintSubstrs []string + wantConsoleURL bool + wantNoAuthLogin bool // hint must not suggest `auth login` + }{ + { + name: "99991672 app_scope_not_applied", + larkCode: 99991672, + legacyErrType: "permission", + wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"}, + wantHintSubstrs: []string{"developer console", "open.feishu.cn"}, + wantConsoleURL: true, + wantNoAuthLogin: true, + }, + { + name: "99991679 missing_scope", + larkCode: 99991679, + legacyErrType: "permission", + wantMsgSubstrs: []string{"unauthorized", "user authorization"}, + wantHintSubstrs: []string{"lark-cli auth login"}, + }, + { + name: "99991673 app_unavailable", + larkCode: 99991673, + legacyErrType: "app_status", + wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"}, + wantHintSubstrs: []string{"tenant admin", "install status"}, + }, + { + name: "99991662 app_disabled", + larkCode: 99991662, + legacyErrType: "app_status", + wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"}, + wantHintSubstrs: []string{"tenant admin", "re-enable"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu, + }) + f.ResolvedIdentity = core.AsUser + + // Mimic the wire shape ErrAPI produces: legacy *ExitError with + // Detail.Type populated by ClassifyLarkError, Detail.Detail + // carrying the permission_violations block so ExtractRequiredScopes + // can recover the missing scope. + scopeForDetail := "drive:drive:read" + exitErr := &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: tc.legacyErrType, + Code: tc.larkCode, + Message: "upstream raw message — must be replaced", + Detail: map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": scopeForDetail}, + }, + }, + }, + } + enrichPermissionError(f, exitErr) + + for _, sub := range tc.wantMsgSubstrs { + if !strings.Contains(exitErr.Detail.Message, sub) { + t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub) + } + } + if exitErr.Detail.Message == "upstream raw message — must be replaced" { + t.Errorf("Message must be rewritten to canonical text; got upstream verbatim") + } + for _, sub := range tc.wantHintSubstrs { + if !strings.Contains(exitErr.Detail.Hint, sub) { + t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub) + } + } + if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") { + t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint) + } + if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" { + t.Error("ConsoleURL should be populated when missing scopes are present") + } + }) + } +} + +// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose +// Detail.Type is neither "permission" nor "app_status" is left untouched — +// no Message rewrite, no Hint rewrite, no ConsoleURL injection. +func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu, + }) + f.ResolvedIdentity = core.AsUser + + for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} { + exitErr := &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: ty, + Code: 99991400, + Message: "untouched", + Hint: "original hint", + }, + } + enrichPermissionError(f, exitErr) + if exitErr.Detail.Message != "untouched" { + t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message) + } + if exitErr.Detail.Hint != "original hint" { + t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint) + } + if exitErr.Detail.ConsoleURL != "" { + t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL) + } + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go index c895c2bc..125cc584 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -9,11 +9,13 @@ import ( "io" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/util" @@ -222,7 +224,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { } if opts.PageAll && opts.Output != "" { - return output.ErrValidation("--output and --page-all are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output") } if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { return err @@ -271,12 +273,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) } - // Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response - // with a per-method recommended `--scope` hint, matching the pre-PR - // behaviour. Per-domain typed migration in stage 2+ will lift this - // into PermissionError.MissingScopes / ConsoleURL on the typed - // envelope; until then the legacy ExitError envelope is preserved. - checkErr := scopeAwareChecker(scopes, opts.As.IsBot()) + // Scope-insufficient (99991679) and all other Lark API codes route through + // errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError + // with MissingScopes / Identity / ConsoleURL populated from the response. + checkErr := ac.CheckResponse if opts.PageAll { return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, @@ -300,51 +300,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { }) } -// scopeAwareChecker returns an error checker that enriches the -// LarkErrUserScopeInsufficient (99991679) business error with a -// per-method recommended `--scope` hint. All other non-zero codes fall -// through to legacy output.ErrAPI (matching pre-PR behaviour). The -// identity parameter is accepted to match the client.ResponseOptions -// CheckError signature; isBotMode is captured from the enclosing call so -// the recommended scope reflects the caller's identity at request time. -// -// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope. -// Stage-2 typed migration will lift this into PermissionError.MissingScopes -// + ConsoleURL on the typed envelope and remove this helper. -func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}, core.Identity) error { - return func(result interface{}, _ core.Identity) error { - resultMap, ok := result.(map[string]interface{}) - if !ok || resultMap == nil { - return nil - } - code, _ := util.ToFloat64(resultMap["code"]) - if code == 0 { - return nil - } - larkCode := int(code) - msg := registry.GetStrFromMap(resultMap, "msg") - - if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 { - identity := "user" - if isBotMode { - identity = "tenant" - } - recommended := registry.SelectRecommendedScope(scopes, identity) - // Stage-1 carve-out: this restores the pre-PR scope-insufficient - // enrichment (recommended scope + auth-login hint) on the legacy - // envelope. The typed migration in stage 2+ will lift this into - // PermissionError.MissingScopes / ConsoleURL on the typed wire. - return output.ErrWithHint(output.ExitAPI, "permission", - fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)) - } - - // Stage-1 carve-out: matches pre-PR behaviour (legacy ExitError + - // ClassifyLarkError). Typed migration is stage-2+. - return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) - } -} - // checkServiceScopes pre-checks user scopes before making the API call. func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error { if ctx.Err() != nil { @@ -366,9 +321,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider } } if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing) } return nil } @@ -388,9 +341,24 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider } } recommended := registry.SelectRecommendedScope(scopes, "user") - return output.ErrWithHint(output.ExitAPI, "permission", - fmt.Sprintf("insufficient permissions (required scope: %s)", recommended), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)) + return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended}) +} + +// newPreflightMissingScopeError constructs a PermissionError for the local +// pre-flight scope check that converges byte-for-byte with the dispatcher's +// BuildAPIError path. Uses the canonical helpers in internal/errclass so +// Hint and Message stay in lock-step with the server-response classifier. +// ConsoleURL is deliberately omitted: the dispatcher only sets it for +// SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this +// pre-flight path is user-perspective SubtypeMissingScope whose recovery is +// `lark-cli auth login --scope ...`, not a console deep-link. +func newPreflightMissingScopeError(brand, appID, identity string, missing []string) *errs.PermissionError { + consoleURL := errclass.ConsoleURL(brand, appID, missing) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")). + WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)). + WithMissingScopes(missing...). + WithIdentity(identity) } // buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest. @@ -412,7 +380,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd return client.RawApiRequest{}, nil, err } if opts.Params == "-" && opts.Data == "-" { - return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)") + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--params and --data cannot both read from stdin (-)").WithParam("--params") } params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO) if err != nil { @@ -429,13 +397,14 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd } val, ok := params[name] if !ok || util.IsEmptyValue(val) { - return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("missing required path parameter: %s", name), - fmt.Sprintf("lark-cli schema %s", schemaPath)) + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "missing required path parameter: %s", name). + WithHint("lark-cli schema %s", schemaPath). + WithParam(name) } valStr := fmt.Sprintf("%v", val) if err := validate.ResourceName(valStr, name); err != nil { - return client.RawApiRequest{}, nil, output.ErrValidation("%s", err) + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err) } url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1) delete(params, name) @@ -451,9 +420,10 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd required, _ := p["required"].(bool) isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size") if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) { - return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("missing required query parameter: %s", name), - fmt.Sprintf("lark-cli schema %s", schemaPath)) + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "missing required query parameter: %s", name). + WithHint("lark-cli schema %s", schemaPath). + WithParam(name) } if exists && !util.IsEmptyValue(value) { queryParams[name] = value @@ -488,7 +458,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd return client.RawApiRequest{}, nil, err } if _, ok := dataFields.(map[string]any); !ok { - return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file") + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a JSON object when used with --file").WithParam("--data") } } diff --git a/errs/ERROR_CONTRACT.md b/errs/ERROR_CONTRACT.md index e834be18..db916983 100644 --- a/errs/ERROR_CONTRACT.md +++ b/errs/ERROR_CONTRACT.md @@ -34,10 +34,12 @@ in production? See **Troubleshooting**. 6. Wrapping is idempotent: re-wrapping an already-typed error returns it unchanged across the `errors.As` / `errors.Unwrap` chain. 7. For the typed-envelope path, exit codes derive from `Category` only - via `output.ExitCodeForCategory`. Two stage-1 exceptions: - `SecurityPolicyError` always exits `1` (fixed by its legacy envelope), - and unmigrated `*output.ExitError` producers carry a hand-set `Code`; - both are retired in the legacy-removal stage. + via `output.ExitCodeForCategory` — including `SecurityPolicyError`, + which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError` + producers still carry a hand-set `Code` until they finish migrating. + `output.ErrBare(code)` is the lone exception: a deliberate + predicate-command signal that bypasses the envelope (see + **Predicate commands** below). ## Wire format @@ -73,9 +75,11 @@ Typed errors render to **stderr** as one JSON object per process exit: | `error.retryable` | wire-stable | `true` when present; omitted when `false` | | per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` | -Carve-out: `SecurityPolicyError` keeps the legacy -`{type: "auth_error", code: "challenge_required"|"access_denied", ...}` -envelope until its consumers migrate. Removal is staged in **Migration**. +`SecurityPolicyError` renders through the same typed envelope as every +other category. `error.type` is `"policy"`, `error.subtype` is one of +`challenge_required` / `access_denied`, and process exit is `6` via +`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been +retired. ## Categories @@ -115,10 +119,11 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`. │ ▼ cmd/root.go handleRootError dispatches: - ├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1 + ├─ output.ErrBare(code) → no envelope (stdout already written); exit = code ├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err) - ├─ *core.ConfigError → asExitError adapts to legacy envelope ↓ - ├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code + │ (includes *errs.SecurityPolicyError → policy envelope, exit 6) + ├─ *core.ConfigError → promoted to typed via errcompat ↑ + ├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code └─ untyped / Cobra error → plain "Error: " (no envelope); exit 1 ``` @@ -127,6 +132,31 @@ stderr. Untyped errors (including Cobra's "required flag missing" / unknown subcommand messages) print plain text and exit `1` — consumers must tolerate that fallback. +### Predicate commands (`output.ErrBare`) + +A small class of commands is **predicates**: they answer a yes/no +question and signal the answer through the shell exit code so callers +can write `if cmd; then ... fi`. `lark-cli auth check` is the canonical +example — its `README` contract is `exit 0 = ok, 1 = missing`. + +These commands deliberately: + +1. write a structured JSON answer to **stdout** themselves, and +2. return `output.ErrBare(exitCode)` to communicate the exit code to + the dispatcher without producing a `stderr` envelope. + +`output.ErrBare` is **not** an error in the typed-envelope sense — it +carries no category, subtype, or message. It is a one-bit output- +control signal that lives outside the contract for the same reason +`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes +without printing anything to stderr: pollution of stderr by a +predicate's negative answer would break `2>/dev/null` log hygiene in +caller scripts. + +New code should not reach for `ErrBare` unless the command is +genuinely a predicate. Anything carrying recoverable error content +belongs in a typed `*errs.XxxError`. + ## Consumers ### Go (in-process) @@ -183,17 +213,25 @@ reworded without notice. ### Quick reference +The canonical producer surface is the **builder API in `errs/types.go`** (per type: struct + `NewXxxError` + chained `WithX` setters live in one place): +each `NewXxxError(subtype, format, args...)` locks `Category` at the +constructor name, requires `Subtype` + `Message` positionally, and exposes +optional fields via chained `.WithX(...)` setters. Struct literals remain +legal for framework dynamic paths (e.g. classifier fanout) but the lint +`CheckTypedErrorCompleteness` still requires `Category` + `Subtype` + +`Message` on any literal it sees. + | Situation | Use | |-----------|-----| -| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` | -| Login required | `&errs.AuthenticationError{...}` | +| Bad user input | `errs.NewValidationError(subtype, msg).WithParam("--flag")` | +| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` | | Token lacks scope | `errclass.BuildAPIError(resp, ctx)` | -| Local config missing | `&errs.ConfigError{...}` | -| Transport failure | `&errs.NetworkError{...}` | +| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` | +| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) | | Lark API error | `errclass.BuildAPIError(resp, ctx)` | -| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` | -| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` | -| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` | +| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` | +| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` | +| Needs `--yes` | `errs.NewConfirmationRequiredError(risk, action, msg)` | ### Authoring discipline @@ -242,8 +280,9 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory` maps `Category` to the shell code. A new exit-code requirement means a new `Category`, not a one-off override at the call site. -(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set -codes during stage 1.) +(Legacy `*output.ExitError` retains hand-set codes until removal; +`SecurityPolicyError` retains a hand-set code on main until the framework +migration PR retires the carve-out — see **Migration**.) #### Split `Message`, `Hint`, and `Cause` @@ -265,15 +304,10 @@ do not inline its `.Error()` into `Message`. Conforming: ```go -return &errs.NetworkError{ - Problem: errs.Problem{ - Category: errs.CategoryNetwork, - Subtype: errs.SubtypeNetworkTransport, - Message: "request to /open-apis failed after 3 retries", - Hint: "check connectivity and retry; set --log-level debug if it persists", - }, - Cause: ioErr, -} +return errs.NewNetworkError(errs.SubtypeNetworkTransport, + "request to /open-apis failed after 3 retries"). + WithHint("check connectivity and retry; set --log-level debug if it persists"). + WithCause(ioErr) ``` Non-conforming: @@ -294,43 +328,51 @@ For positional arguments, use the canonical name without dashes ### Constructing typed errors -The minimal struct literal: +Prefer the **builder API**. The constructor pins `Category` + `Subtype` + +`Message`, the chained setters fill optional fields, and the resulting +value retains its concrete `*XxxError` pointer through the chain so +type-specific setters remain reachable to the end: ```go -return &errs.ValidationError{ - Problem: errs.Problem{ - Category: errs.CategoryValidation, - Subtype: errs.SubtypeInvalidArgument, - Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr), - }, - Param: "--data", -} +return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--data must be a valid JSON object: %v", parseErr). + WithParam("--data") ``` +Why builder over struct literal: + +- `Category` is locked at the function name — caller cannot mis-specify it +- `Subtype` and `Message` are positional arguments — `go build` rejects + the call site if either is missing +- The chain reads top-down: required identity first, optional fields after +- Message is `fmt.Sprintf`-formatted from `(format, args...)`, matching + `fmt.Errorf` muscle memory and avoiding a separate `Sprintf` line + +Struct literals remain legal — `CheckTypedErrorCompleteness` continues to +enforce `Category` + `Subtype` + `Message` on any literal it sees — and +the framework classifier (`internal/errclass/classify.go`) still uses +them on the dynamic dispatch path where a `Problem` value is composed +once and wrapped per Category branch. Outside that pattern, new code +should reach for the builder. + Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`) -remain callable during migration; new code should prefer the struct -literal so `Hint`, `Param`, `Cause`, and other extension fields stay -available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause). +remain callable during migration but are `// Deprecated:` — new code goes +through the builder. #### Shortcut `Execute` walkthrough Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy form is `output.ErrValidation("--duration-minutes must be between 1 and -1440")`. The typed migration target: +1440")`. The typed migration target (builder form): ```go Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { duration := runtime.Int("duration-minutes") if duration < 1 || duration > 1440 { - return &errs.ValidationError{ - Problem: errs.Problem{ - Category: errs.CategoryValidation, - Subtype: errs.SubtypeInvalidArgument, - Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration), - Hint: "pass a value in [1, 1440]", - }, - Param: "--duration-minutes", - } + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--duration-minutes must be between 1 and 1440, got %d", duration). + WithHint("pass a value in [1, 1440]"). + WithParam("--duration-minutes") } _, err := runtime.DoAPI(req, opts) @@ -360,7 +402,7 @@ cover the decision: | Source | Decision | Example | |--------|----------|---------| | Helper returned a typed `*errs.*Error` | Return unchanged | `return err` | -| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return &errs.ValidationError{Problem: ..., Cause: jsonErr}` | +| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --data: %v", jsonErr).WithCause(jsonErr)` | | SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` | | Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` | @@ -391,8 +433,11 @@ through `runtime.DoAPI`. #### Add a Subtype -1. Add a constant in `errs/subtypes.go` (framework) or - `errs/subtypes_service_.go` (service). +1. Add a constant in `errs/subtypes.go` under the right Category block. + Subtypes are framework-shared — service-specific Subtypes are an + anti-pattern (the wire `code` field already identifies the source + service; Subtype encodes cross-service semantics like `not_found`, + `quota_exceeded`). 2. If it maps from a Lark code, register the mapping in `internal/errclass/codemeta_.go`. 3. Add a dispatch test in `internal/errclass/classify_test.go`. @@ -409,10 +454,9 @@ emits a warning to keep them visible. Rare; the existing structs cover the 9 Categories with room. If you must: -1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a - nil-receiver-safe `Unwrap()` if it carries `Cause`. +1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field. 2. Add an `IsXxx` predicate in `errs/predicates.go`. -3. Add a wire-format pin in `errs/marshal_test.go`. +3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`. `CheckProblemEmbed` enforces the `Problem` embed at lint time. New top-level wire fields are forbidden — per-Subtype data goes into the @@ -448,51 +492,36 @@ will be removed once business migration completes. ## Migration -The error-contract refactor lands in stages. This PR is **stage 1**, and -its scope is **strictly framework-only**: every production wire shape -matches pre-PR byte-for-byte (additive fields only where the legacy slot -had no subtype emission). Stage 1 ships infrastructure; behavioural -migration of any specific path lives in later stages. +**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline. -Stages: +Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner. -1. **Framework slice — this PR.** Ships the `errs/` typed taxonomy, - classifier (`internal/errclass`), promotion stub (`internal/errcompat`, - passthrough in stage 1), dispatcher hook (`WriteTypedErrorEnvelope`), - and six lint guards (forbidigo + five AST checks). Wire shapes - preserved byte-for-byte versus pre-PR, with **one intentional semantic - fix**: config-class errors (`*core.ConfigError`) now exit `3` instead - of `2`, aligning with `ExitCodeForCategory` (config errors share the - auth exit slot per the taxonomy). The classifier and promote helpers - are *shipped but unused* in production paths — they exist so stage 2+ - migrations can plug in without re-architecting. -2. **`SecurityPolicyError` typed envelope** — replace the legacy - `type: "auth_error"` carve-out with the typed shape. -3. **Business-domain migration**, one PR per domain in declared order: - `task → drive → calendar → im → mail → whiteboard → contact`. Each PR - moves the domain's `output.ErrAPI(...)` / `output.ErrAuth(...)` / - `output.ErrWithHint(...)` call sites to typed constructors or - `BuildAPIError`, removes its Deprecated annotations, and announces the - wire change explicitly. -4. **Framework-boundary migration**: `client.WrapDoAPIError` and - `client.WrapJSONResponseParseError` flip to typed wrap; - `client.CheckResponse` adopts `errclass.BuildAPIError`; - `internal/client/client.go resolveAccessToken` adopts the typed - `NeedAuthorizationError → *errs.AuthenticationError` recognition; - `cmd/auth/scopes.go` and `cmd/service/service.go` adopt typed - `*errs.PermissionError`; `errcompat.PromoteConfigError` lifts the - `Type="config"` (and later `Type="auth"`) branches to typed. -5. **Legacy removal** — once `git grep '\*output\.ExitError'` returns no - production hits, delete `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, - `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and - `ErrorEnvelope`. +### Current state -During migration, helper assertions accept both shapes (see -`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) -so the build stays green domain-by-domain. +1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting. -Before / after at a call site (illustrative — actually performed in -stage 3): +2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type. + +### Next: framework migration PR (planned) + +A single PR consolidates the work the original §9 spec split across PRs 2–4 — restricted to framework code, no business sweep: + +- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`. +- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`. +- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder). +- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code). + +### Business-domain migration (self-service, no central timeline) + +Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover. + +Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally. + +### Legacy removal + +Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date. + +### Before / after at a call site ```go // before (legacy) @@ -502,6 +531,16 @@ return output.ErrAPI(larkCode, "create event failed", resp.RawBody()) return errclass.BuildAPIError(parsedResp, cc) ``` +```go +// before (legacy validation) +return output.ErrValidation("--duration-minutes must be between 1 and 1440") + +// after (builder) +return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--duration-minutes must be between 1 and 1440, got %d", duration). + WithParam("--duration-minutes") +``` + ## Troubleshooting **Envelope shows `type=api subtype=unknown` for what should be a more diff --git a/errs/marshal_test.go b/errs/marshal_test.go index d9ae9158..b495ad78 100644 --- a/errs/marshal_test.go +++ b/errs/marshal_test.go @@ -55,6 +55,28 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) { } } +func TestPermissionError_RequestedGrantedMarshal(t *testing.T) { + err := NewPermissionError(SubtypeMissingScope, "partial grant"). + WithRequestedScopes("docx:document", "im:message:send"). + WithGrantedScopes("docx:document"). + WithMissingScopes("im:message:send") + + b, e := json.Marshal(err) + if e != nil { + t.Fatal(e) + } + got := string(b) + for _, want := range []string{ + `"requested_scopes":["docx:document","im:message:send"]`, + `"granted_scopes":["docx:document"]`, + `"missing_scopes":["im:message:send"]`, + } { + if !strings.Contains(got, want) { + t.Errorf("envelope missing %s\nactual: %s", want, got) + } + } +} + func TestValidationError_MarshalJSON(t *testing.T) { ve := &ValidationError{ Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"}, @@ -116,33 +138,26 @@ func TestConfigError_MarshalJSON(t *testing.T) { func TestNetworkError_MarshalJSON(t *testing.T) { ne := &NetworkError{ - Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"}, - CauseKind: "timeout", + Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"}, } b, _ := json.Marshal(ne) s := string(b) for _, want := range []string{ `"type":"network"`, - `"subtype":"transport"`, - `"cause":"timeout"`, + `"subtype":"timeout"`, } { if !strings.Contains(s, want) { t.Errorf("missing %q in %s", want, s) } } - - // CauseKind omitempty when "" - ne2 := &NetworkError{Problem: Problem{Category: CategoryNetwork, Message: "x"}} - b2, _ := json.Marshal(ne2) - if strings.Contains(string(b2), `"cause"`) { - t.Errorf("cause should be omitted when empty; got %s", b2) + if strings.Contains(s, `"cause"`) { + t.Errorf("cause field should no longer be on the wire; got %s", s) } } func TestAPIError_MarshalJSON(t *testing.T) { ae := &APIError{ Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true}, - Detail: map[string]any{"raw": "value"}, } b, _ := json.Marshal(ae) s := string(b) @@ -151,19 +166,39 @@ func TestAPIError_MarshalJSON(t *testing.T) { `"subtype":"rate_limit"`, `"code":99991400`, `"retryable":true`, - `"detail":{`, - `"raw":"value"`, } { if !strings.Contains(s, want) { t.Errorf("missing %q in %s", want, s) } } +} - // Detail omitempty when nil - ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}} - b2, _ := json.Marshal(ae2) - if strings.Contains(string(b2), `"detail"`) { - t.Errorf("detail should be omitted when nil; got %s", b2) +// TestProblem_MarshalJSON_Troubleshooter pins the upstream Lark API +// troubleshooter URL (resp.error.troubleshooter) surfacing on the wire under +// "troubleshooter". Carried via Problem so any typed error that embeds it +// inherits the field — populated by errclass.BuildAPIError before the +// category switch. +func TestProblem_MarshalJSON_Troubleshooter(t *testing.T) { + ae := &APIError{ + Problem: Problem{ + Category: CategoryAPI, + Subtype: SubtypeUnknown, + Code: 99991400, + Message: "x", + Troubleshooter: "https://open.feishu.cn/document/troubleshoot/abc", + }, + } + b, _ := json.Marshal(ae) + s := string(b) + if !strings.Contains(s, `"troubleshooter":"https://open.feishu.cn/document/troubleshoot/abc"`) { + t.Errorf("missing troubleshooter in %s", s) + } + + // Absent Troubleshooter must omit the wire key. + bare := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}} + b2, _ := json.Marshal(bare) + if strings.Contains(string(b2), `"troubleshooter"`) { + t.Errorf("absent Troubleshooter must omit wire key; got %s", string(b2)) } } @@ -185,6 +220,32 @@ func TestSecurityPolicyError_MarshalJSON(t *testing.T) { } } +// Pin per-Subtype symmetry: SubtypeAccessDenied must serialize the same +// envelope shape as SubtypeChallengeRequired so callers can switch on +// subtype without conditional field probing. The constructor + builder +// path (mirroring how callsites actually construct these) is exercised +// here rather than the struct literal, since SubtypeAccessDenied is the +// path threaded through cmd/* sites that surface policy-deny outcomes. +func TestSecurityPolicyError_MarshalJSON_AccessDenied(t *testing.T) { + err := NewSecurityPolicyError(SubtypeAccessDenied, "user denied"). + WithChallengeURL("https://chal.example/2") + + b, e := json.Marshal(err) + if e != nil { + t.Fatal(e) + } + got := string(b) + for _, want := range []string{ + `"type":"policy"`, + `"subtype":"access_denied"`, + `"challenge_url":"https://chal.example/2"`, + } { + if !strings.Contains(got, want) { + t.Errorf("envelope missing %s\nactual: %s", want, got) + } + } +} + func TestContentSafetyError_MarshalJSON(t *testing.T) { cse := &ContentSafetyError{ Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"}, diff --git a/errs/predicates.go b/errs/predicates.go index d3b1c8e9..736aefb9 100644 --- a/errs/predicates.go +++ b/errs/predicates.go @@ -86,3 +86,12 @@ func IsAuthentication(err error) bool { var x *AuthenticationError; return error // IsConfig reports whether err is a *ConfigError. func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) } + +// IsTyped reports whether err is or wraps any of the typed *errs.* errors +// in this package (i.e. implements the TypedError interface). Used by call +// sites that need to pass already-classified errors through unchanged +// instead of blanket-rewrapping them as a different category. +func IsTyped(err error) bool { + var t TypedError + return errors.As(err, &t) +} diff --git a/errs/problem.go b/errs/problem.go index f9270f26..0208c873 100644 --- a/errs/problem.go +++ b/errs/problem.go @@ -14,16 +14,21 @@ package errs // never appears on the wire. // - No DocURL field. PermissionError carries the same intent via its typed // ConsoleURL extension; other typed errors do not link out. +// - Troubleshooter is the upstream Lark API's diagnostic URL (resp.error. +// troubleshooter). Carried universally so any classified error can surface +// it; populated by errclass.BuildAPIError when the upstream response +// includes it, otherwise absent. // - Retryable uses omitempty so only `true` is emitted; consumers treat // absence as false. type Problem struct { - Category Category `json:"type"` - Subtype Subtype `json:"subtype,omitempty"` - Code int `json:"code,omitempty"` - Message string `json:"message"` - Hint string `json:"hint,omitempty"` - LogID string `json:"log_id,omitempty"` - Retryable bool `json:"retryable,omitempty"` + Category Category `json:"type"` + Subtype Subtype `json:"subtype,omitempty"` + Code int `json:"code,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + LogID string `json:"log_id,omitempty"` + Troubleshooter string `json:"troubleshooter,omitempty"` + Retryable bool `json:"retryable,omitempty"` } // Error satisfies the standard `error` interface. A nil receiver is treated diff --git a/errs/subtypes.go b/errs/subtypes.go index 35b2d278..21a9561b 100644 --- a/errs/subtypes.go +++ b/errs/subtypes.go @@ -34,7 +34,8 @@ const ( SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment) SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable - SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant + SubtypeAppDisabled Subtype = "app_disabled" // app currently disabled in this tenant (was installed/enabled before) + SubtypePermissionDenied Subtype = "permission_denied" // resource-level permission denial (authenticated but lacks rights for this resource, HTTP 403 / gRPC PERMISSION_DENIED alignment) ) // CategoryConfig subtypes @@ -46,7 +47,11 @@ const ( // CategoryNetwork subtypes const ( - SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind + SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches + SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout + SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure + SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure + SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx ) // CategoryAPI subtypes @@ -57,6 +62,10 @@ const ( SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported) SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner + SubtypeNotFound Subtype = "not_found" // referenced resource does not exist (HTTP 404 alignment) + SubtypeServerError Subtype = "server_error" // upstream server-side transient error (HTTP 5xx alignment, retryable) + SubtypeQuotaExceeded Subtype = "quota_exceeded" // resource quota / collection size limit reached (assignees, followers, members, etc.) + SubtypeAlreadyExists Subtype = "already_exists" // idempotency violation: resource already exists in target state ) // CategoryPolicy subtypes (security-policy envelope shape) @@ -69,7 +78,12 @@ const ( const ( SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON + SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read) + SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save) // Generic untyped error lifted to InternalError uses SubtypeUnknown. ) -// CategoryConfirmation subtypes intentionally have no declarations yet. +// CategoryConfirmation subtypes +const ( + SubtypeConfirmationRequired Subtype = "confirmation_required" // high-risk operation needs explicit --yes +) diff --git a/errs/subtypes_service_task.go b/errs/subtypes_service_task.go deleted file mode 100644 index 04464411..00000000 --- a/errs/subtypes_service_task.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package errs - -// Service-specific Subtype declarations. Per-service files follow the -// naming pattern subtypes_service_.go so the framework's closed -// Subtype enum stays readable while service taxonomies remain visible. - -// Task service subtypes — consumed by internal/errclass/codemeta_task.go. -const ( - SubtypeTaskInvalidParams Subtype = "task_invalid_params" - SubtypeTaskPermissionDenied Subtype = "task_permission_denied" - SubtypeTaskNotFound Subtype = "task_not_found" - SubtypeTaskConflict Subtype = "task_conflict" - SubtypeTaskServerError Subtype = "task_server_error" - SubtypeTaskAssigneeLimit Subtype = "task_assignee_limit" - SubtypeTaskFollowerLimit Subtype = "task_follower_limit" - SubtypeTaskTasklistMemberLimit Subtype = "task_tasklist_member_limit" - SubtypeTaskReminderExists Subtype = "task_reminder_exists" -) diff --git a/errs/types.go b/errs/types.go index aa571844..e292098a 100644 --- a/errs/types.go +++ b/errs/types.go @@ -3,6 +3,59 @@ package errs +import ( + "fmt" + "slices" +) + +// formatMessage applies fmt.Sprintf only when args are present, so a +// caller passing a literal message with a stray "%" (e.g. "disk 100% full") +// is not rendered as "%!(NOVERB)". `go vet -printf` catches most accidental +// format misuse upstream; this guard makes the constructor safe even when +// the message string is dynamically composed. +func formatMessage(format string, args []any) string { + if len(args) == 0 { + return format + } + return fmt.Sprintf(format, args...) +} + +// Typed error types and their builder APIs. +// +// Each typed error has: +// - A struct embedding Problem, with type-specific extension fields +// - A nil-safe Unwrap() method when the struct carries a Cause field +// - A NewXxxError(subtype, format, args...) constructor — Category locked +// by the function name, Subtype + Message positional and required +// - Chainable WithX(...) setters that return the concrete *XxxError pointer +// so type-specific setters remain reachable to the end of the chain +// +// Preferred shape for new code: +// +// return errs.NewValidationError(errs.SubtypeInvalidArgument, +// "invalid --start: %v", err). +// WithHint("expected RFC3339, e.g. 2026-05-26T10:00:00Z"). +// WithParam("--start") +// +// Category is locked by the constructor name — it can never be mis-specified +// at the call site. Subtype + Message are required positional arguments so the +// compiler refuses to build a typed error missing either identity field. +// Subtype well-formedness is enforced at PR time by the lint guard +// CheckDeclaredSubtype (`lint/errscontract`), not at runtime, to avoid +// coupling the typed package to a registry. ad_hoc_* subtypes are accepted +// at runtime; CheckAdHocSubtype emits a follow-up warning. + +// TypedError is implemented by all typed errors in this package. +// It identifies a value as a typed envelope producer to the dispatcher, +// which uses it to short-circuit promotion when the outer error is +// already typed (avoiding overwrite of producer-set Subtype/Hint). +type TypedError interface { + error + ProblemDetail() *Problem +} + +// ============================== ValidationError ============================== + // ValidationError is the typed error for CategoryValidation. // Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; // it is intentionally not serialized. @@ -22,6 +75,60 @@ func (e *ValidationError) Unwrap() error { return e.Cause } +// Error returns the typed error message. Nil-safe — falls back to "" when the +// receiver is a typed nil pointer, mirroring the embedded Problem.Error() guard +// that promote-through-value-embed would otherwise bypass. +func (e *ValidationError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +// NewValidationError constructs a *ValidationError with Category locked to +// CategoryValidation and Message formatted via fmt.Sprintf(format, args...). +func NewValidationError(subtype Subtype, format string, args ...any) *ValidationError { + return &ValidationError{ + Problem: Problem{ + Category: CategoryValidation, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *ValidationError) WithHint(format string, args ...any) *ValidationError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ValidationError) WithLogID(logID string) *ValidationError { + e.LogID = logID + return e +} + +func (e *ValidationError) WithCode(code int) *ValidationError { + e.Code = code + return e +} + +func (e *ValidationError) WithRetryable() *ValidationError { + e.Retryable = true + return e +} + +func (e *ValidationError) WithParam(param string) *ValidationError { + e.Param = param + return e +} + +func (e *ValidationError) WithCause(cause error) *ValidationError { + e.Cause = cause + return e +} + +// =========================== AuthenticationError ============================= + // AuthenticationError is the typed error for CategoryAuthentication. // Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; // it is intentionally not serialized. @@ -39,17 +146,150 @@ func (e *AuthenticationError) Unwrap() error { return e.Cause } -// PermissionError is the typed error for CategoryAuthorization. -type PermissionError struct { - Problem - MissingScopes []string `json:"missing_scopes,omitempty"` - Identity string `json:"identity,omitempty"` - ConsoleURL string `json:"console_url,omitempty"` +// Error is nil-receiver safe; see ValidationError.Error. +func (e *AuthenticationError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() } -// ConfigError is the typed error for CategoryConfig. +func NewAuthenticationError(subtype Subtype, format string, args ...any) *AuthenticationError { + return &AuthenticationError{ + Problem: Problem{ + Category: CategoryAuthentication, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *AuthenticationError) WithHint(format string, args ...any) *AuthenticationError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *AuthenticationError) WithLogID(logID string) *AuthenticationError { + e.LogID = logID + return e +} + +func (e *AuthenticationError) WithCode(code int) *AuthenticationError { + e.Code = code + return e +} + +func (e *AuthenticationError) WithRetryable() *AuthenticationError { + e.Retryable = true + return e +} + +func (e *AuthenticationError) WithUserOpenID(id string) *AuthenticationError { + e.UserOpenID = id + return e +} + +func (e *AuthenticationError) WithCause(cause error) *AuthenticationError { + e.Cause = cause + return e +} + +// ============================= PermissionError =============================== + +// PermissionError is the typed error for CategoryAuthorization. // Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; // it is intentionally not serialized. +type PermissionError struct { + Problem + MissingScopes []string `json:"missing_scopes,omitempty"` + RequestedScopes []string `json:"requested_scopes,omitempty"` + GrantedScopes []string `json:"granted_scopes,omitempty"` + Identity string `json:"identity,omitempty"` + ConsoleURL string `json:"console_url,omitempty"` + Cause error `json:"-"` +} + +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *PermissionError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *PermissionError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewPermissionError(subtype Subtype, format string, args ...any) *PermissionError { + return &PermissionError{ + Problem: Problem{ + Category: CategoryAuthorization, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *PermissionError) WithHint(format string, args ...any) *PermissionError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *PermissionError) WithLogID(logID string) *PermissionError { + e.LogID = logID + return e +} + +func (e *PermissionError) WithCode(code int) *PermissionError { + e.Code = code + return e +} + +func (e *PermissionError) WithRetryable() *PermissionError { + e.Retryable = true + return e +} + +func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { + e.MissingScopes = slices.Clone(scopes) + return e +} + +func (e *PermissionError) WithRequestedScopes(scopes ...string) *PermissionError { + e.RequestedScopes = slices.Clone(scopes) + return e +} + +func (e *PermissionError) WithGrantedScopes(scopes ...string) *PermissionError { + e.GrantedScopes = slices.Clone(scopes) + return e +} + +func (e *PermissionError) WithIdentity(identity string) *PermissionError { + e.Identity = identity + return e +} + +func (e *PermissionError) WithConsoleURL(url string) *PermissionError { + e.ConsoleURL = url + return e +} + +func (e *PermissionError) WithCause(cause error) *PermissionError { + e.Cause = cause + return e +} + +// =============================== ConfigError ================================= + +// ConfigError is the typed error for CategoryConfig. Cause preserves an +// optional wrapped sentinel for errors.Is / errors.Unwrap; it is +// intentionally not serialized. type ConfigError struct { Problem Field string `json:"field,omitempty"` @@ -64,15 +304,63 @@ func (e *ConfigError) Unwrap() error { return e.Cause } -// NetworkError is the typed error for CategoryNetwork. -// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the -// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an -// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally -// not serialized. +// Error is nil-receiver safe; see ValidationError.Error. +func (e *ConfigError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewConfigError(subtype Subtype, format string, args ...any) *ConfigError { + return &ConfigError{ + Problem: Problem{ + Category: CategoryConfig, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *ConfigError) WithHint(format string, args ...any) *ConfigError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ConfigError) WithLogID(logID string) *ConfigError { + e.LogID = logID + return e +} + +func (e *ConfigError) WithCode(code int) *ConfigError { + e.Code = code + return e +} + +func (e *ConfigError) WithRetryable() *ConfigError { + e.Retryable = true + return e +} + +func (e *ConfigError) WithField(field string) *ConfigError { + e.Field = field + return e +} + +func (e *ConfigError) WithCause(cause error) *ConfigError { + e.Cause = cause + return e +} + +// =============================== NetworkError ================================ + +// NetworkError is the typed error for CategoryNetwork. The Subtype carries +// the failure taxonomy: timeout / tls / dns / server_error, with transport +// as the fallback. Cause preserves an optional wrapped sentinel for +// errors.Is / errors.Unwrap; it is intentionally not serialized. type NetworkError struct { Problem - CauseKind string `json:"cause,omitempty"` - Cause error `json:"-"` + Cause error `json:"-"` } // Unwrap is nil-receiver safe; see ValidationError.Unwrap. @@ -83,13 +371,112 @@ func (e *NetworkError) Unwrap() error { return e.Cause } -// APIError is the typed error for CategoryAPI (catch-all for classified Lark API -// business errors). Detail preserves the raw Lark error map for diagnostics. +// Error is nil-receiver safe; see ValidationError.Error. +func (e *NetworkError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewNetworkError(subtype Subtype, format string, args ...any) *NetworkError { + return &NetworkError{ + Problem: Problem{ + Category: CategoryNetwork, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *NetworkError) WithHint(format string, args ...any) *NetworkError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *NetworkError) WithLogID(logID string) *NetworkError { + e.LogID = logID + return e +} + +func (e *NetworkError) WithCode(code int) *NetworkError { + e.Code = code + return e +} + +func (e *NetworkError) WithRetryable() *NetworkError { + e.Retryable = true + return e +} + +func (e *NetworkError) WithCause(cause error) *NetworkError { + e.Cause = cause + return e +} + +// ================================ APIError =================================== + +// APIError is the typed error for CategoryAPI (catch-all for classified Lark +// API business errors). Cause preserves an optional wrapped sentinel for +// errors.Is / errors.Unwrap; it is intentionally not serialized. type APIError struct { Problem - Detail map[string]any `json:"detail,omitempty"` + Cause error `json:"-"` } +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *APIError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *APIError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewAPIError(subtype Subtype, format string, args ...any) *APIError { + return &APIError{ + Problem: Problem{ + Category: CategoryAPI, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *APIError) WithHint(format string, args ...any) *APIError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *APIError) WithLogID(logID string) *APIError { + e.LogID = logID + return e +} + +func (e *APIError) WithCode(code int) *APIError { + e.Code = code + return e +} + +func (e *APIError) WithRetryable() *APIError { + e.Retryable = true + return e +} + +func (e *APIError) WithCause(cause error) *APIError { + e.Cause = cause + return e +} + +// =========================== SecurityPolicyError ============================= + // SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes. // Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001. type SecurityPolicyError struct { @@ -106,14 +493,125 @@ func (e *SecurityPolicyError) Unwrap() error { return e.Cause } +// Error is nil-receiver safe; see ValidationError.Error. +func (e *SecurityPolicyError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewSecurityPolicyError(subtype Subtype, format string, args ...any) *SecurityPolicyError { + return &SecurityPolicyError{ + Problem: Problem{ + Category: CategoryPolicy, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *SecurityPolicyError) WithHint(format string, args ...any) *SecurityPolicyError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *SecurityPolicyError) WithLogID(logID string) *SecurityPolicyError { + e.LogID = logID + return e +} + +func (e *SecurityPolicyError) WithCode(code int) *SecurityPolicyError { + e.Code = code + return e +} + +func (e *SecurityPolicyError) WithRetryable() *SecurityPolicyError { + e.Retryable = true + return e +} + +func (e *SecurityPolicyError) WithChallengeURL(url string) *SecurityPolicyError { + e.ChallengeURL = url + return e +} + +func (e *SecurityPolicyError) WithCause(cause error) *SecurityPolicyError { + e.Cause = cause + return e +} + +// ============================ ContentSafetyError ============================= + // ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes. +// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; +// it is intentionally not serialized. type ContentSafetyError struct { Problem Rules []string `json:"rules,omitempty"` + Cause error `json:"-"` } -// InternalError is the typed error for CategoryInternal. -// Cause is preserved for logging but not emitted on the wire. +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *ContentSafetyError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *ContentSafetyError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewContentSafetyError(subtype Subtype, format string, args ...any) *ContentSafetyError { + return &ContentSafetyError{ + Problem: Problem{ + Category: CategoryPolicy, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *ContentSafetyError) WithHint(format string, args ...any) *ContentSafetyError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ContentSafetyError) WithLogID(logID string) *ContentSafetyError { + e.LogID = logID + return e +} + +func (e *ContentSafetyError) WithCode(code int) *ContentSafetyError { + e.Code = code + return e +} + +func (e *ContentSafetyError) WithRetryable() *ContentSafetyError { + e.Retryable = true + return e +} + +func (e *ContentSafetyError) WithRules(rules ...string) *ContentSafetyError { + e.Rules = slices.Clone(rules) + return e +} + +func (e *ContentSafetyError) WithCause(cause error) *ContentSafetyError { + e.Cause = cause + return e +} + +// =============================== InternalError =============================== + +// InternalError is the typed error for CategoryInternal. Cause is preserved +// for logging but not emitted on the wire. type InternalError struct { Problem Cause error `json:"-"` @@ -127,10 +625,127 @@ func (e *InternalError) Unwrap() error { return e.Cause } +// Error is nil-receiver safe; see ValidationError.Error. +func (e *InternalError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +func NewInternalError(subtype Subtype, format string, args ...any) *InternalError { + return &InternalError{ + Problem: Problem{ + Category: CategoryInternal, + Subtype: subtype, + Message: formatMessage(format, args), + }, + } +} + +func (e *InternalError) WithHint(format string, args ...any) *InternalError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *InternalError) WithLogID(logID string) *InternalError { + e.LogID = logID + return e +} + +func (e *InternalError) WithCode(code int) *InternalError { + e.Code = code + return e +} + +func (e *InternalError) WithRetryable() *InternalError { + e.Retryable = true + return e +} + +func (e *InternalError) WithCause(cause error) *InternalError { + e.Cause = cause + return e +} + +// ========================= ConfirmationRequiredError ========================= + +// Risk classifies the impact of a confirmation-required operation. Every +// ConfirmationRequiredError MUST populate Risk; callers without a known +// risk level use RiskUnknown so the envelope is never wire-invalid. +const ( + RiskRead = "read" + RiskWrite = "write" + RiskHighRiskWrite = "high-risk-write" + RiskUnknown = "unknown" +) + // ConfirmationRequiredError is the typed error for CategoryConfirmation. -// Risk is one of: "read" | "write" | "high-risk-write". +// Risk is one of: "read" | "write" | "high-risk-write" | "unknown". +// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap; +// it is intentionally not serialized. type ConfirmationRequiredError struct { Problem Risk string `json:"risk"` Action string `json:"action"` + Cause error `json:"-"` +} + +// Unwrap is nil-receiver safe; see ValidationError.Unwrap. +func (e *ConfirmationRequiredError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Error is nil-receiver safe; see ValidationError.Error. +func (e *ConfirmationRequiredError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} + +// NewConfirmationRequiredError constructs a *ConfirmationRequiredError. +// Risk + Action are wire-required (non-omitempty). Empty inputs are +// normalized at the constructor boundary so callers cannot build a +// wire-invalid envelope: risk falls back to RiskUnknown, action to +// "unknown". risk is one of: "read" | "write" | "high-risk-write". +func NewConfirmationRequiredError(risk, action, format string, args ...any) *ConfirmationRequiredError { + if risk == "" { + risk = RiskUnknown + } + if action == "" { + action = "unknown" + } + return &ConfirmationRequiredError{ + Problem: Problem{ + Category: CategoryConfirmation, + Subtype: SubtypeConfirmationRequired, + Message: formatMessage(format, args), + }, + Risk: risk, + Action: action, + } +} + +func (e *ConfirmationRequiredError) WithHint(format string, args ...any) *ConfirmationRequiredError { + e.Hint = formatMessage(format, args) + return e +} + +func (e *ConfirmationRequiredError) WithLogID(logID string) *ConfirmationRequiredError { + e.LogID = logID + return e +} + +func (e *ConfirmationRequiredError) WithCode(code int) *ConfirmationRequiredError { + e.Code = code + return e +} + +func (e *ConfirmationRequiredError) WithCause(cause error) *ConfirmationRequiredError { + e.Cause = cause + return e } diff --git a/errs/types_test.go b/errs/types_test.go index 8d626f1f..b27d8373 100644 --- a/errs/types_test.go +++ b/errs/types_test.go @@ -1,20 +1,24 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package errs +package errs_test import ( "encoding/json" "errors" "strings" "testing" + + "github.com/larksuite/cli/errs" ) +// ============================== JSON shape & embed ============================== + func TestPermissionErrorJSONShape(t *testing.T) { - perm := &PermissionError{ - Problem: Problem{ - Category: CategoryAuthorization, - Subtype: SubtypeMissingScope, + perm := &errs.PermissionError{ + Problem: errs.Problem{ + Category: errs.CategoryAuthorization, + Subtype: errs.SubtypeMissingScope, Message: "x", }, MissingScopes: []string{"docx:document"}, @@ -53,35 +57,35 @@ func TestPermissionErrorJSONShape(t *testing.T) { // PermissionError embeds Problem. ProblemOf works around this by routing // via the unexported problemCarrier interface. func TestEmbedSemanticChasm(t *testing.T) { - perm := &PermissionError{ - Problem: Problem{ - Category: CategoryAuthorization, - Subtype: SubtypeMissingScope, + perm := &errs.PermissionError{ + Problem: errs.Problem{ + Category: errs.CategoryAuthorization, + Subtype: errs.SubtypeMissingScope, Message: "missing", }, } - var p *Problem + var p *errs.Problem if errors.As(perm, &p) { t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed") } - got, ok := ProblemOf(perm) + got, ok := errs.ProblemOf(perm) if !ok { t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem") } if got != &perm.Problem { t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem) } - if got.Category != CategoryAuthorization { - t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization) + if got.Category != errs.CategoryAuthorization { + t.Errorf("extracted Problem.Category = %q, want %q", got.Category, errs.CategoryAuthorization) } } func TestSecurityPolicyErrorUnwrap(t *testing.T) { orig := errors.New("transport stalled") - spe := &SecurityPolicyError{ - Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"}, + spe := &errs.SecurityPolicyError{ + Problem: errs.Problem{Category: errs.CategoryPolicy, Subtype: errs.Subtype("challenge_required"), Message: "blocked"}, Cause: orig, } if got := errors.Unwrap(spe); got != orig { @@ -106,12 +110,12 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) { name string call func() error }{ - {"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }}, - {"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }}, - {"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }}, - {"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }}, - {"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }}, - {"InternalError", func() error { var e *InternalError; return e.Unwrap() }}, + {"ValidationError", func() error { var e *errs.ValidationError; return e.Unwrap() }}, + {"AuthenticationError", func() error { var e *errs.AuthenticationError; return e.Unwrap() }}, + {"ConfigError", func() error { var e *errs.ConfigError; return e.Unwrap() }}, + {"NetworkError", func() error { var e *errs.NetworkError; return e.Unwrap() }}, + {"SecurityPolicyError", func() error { var e *errs.SecurityPolicyError; return e.Unwrap() }}, + {"InternalError", func() error { var e *errs.InternalError; return e.Unwrap() }}, } for _, c := range checks { t.Run(c.name, func(t *testing.T) { @@ -127,6 +131,44 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) { } } +// TestTypedError_NilReceiverError pins the nil-receiver guard on every typed +// error's Error(). Each typed error must define its own Error() method that +// nil-guards the outer pointer; the embedded Problem.Error()'s nil guard is +// bypassed because Go must dereference the outer pointer to reach the embedded +// field via value-embed promotion. +func TestTypedError_NilReceiverError(t *testing.T) { + // Each typed error must define its own Error() method that nil-guards + // the outer pointer; the embedded Problem.Error()'s nil guard is bypassed + // because Go must dereference the outer pointer to reach the embedded field. + cases := []struct { + name string + err error + }{ + {"ValidationError", (*errs.ValidationError)(nil)}, + {"AuthenticationError", (*errs.AuthenticationError)(nil)}, + {"PermissionError", (*errs.PermissionError)(nil)}, + {"ConfigError", (*errs.ConfigError)(nil)}, + {"NetworkError", (*errs.NetworkError)(nil)}, + {"APIError", (*errs.APIError)(nil)}, + {"InternalError", (*errs.InternalError)(nil)}, + {"SecurityPolicyError", (*errs.SecurityPolicyError)(nil)}, + {"ContentSafetyError", (*errs.ContentSafetyError)(nil)}, + {"ConfirmationRequiredError", (*errs.ConfirmationRequiredError)(nil)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("(*%s)(nil).Error() panicked: %v", tc.name, r) + } + }() + if got := tc.err.Error(); got != "" { + t.Errorf("(*%s)(nil).Error() = %q, want empty string", tc.name, got) + } + }) + } +} + // TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the // nil-safety guard above does not silently drop a real Cause on non-nil // receivers. Without this, a buggy refactor could change `return e.Cause` to @@ -137,12 +179,12 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) { name string err interface{ Unwrap() error } }{ - {"ValidationError", &ValidationError{Cause: cause}}, - {"AuthenticationError", &AuthenticationError{Cause: cause}}, - {"ConfigError", &ConfigError{Cause: cause}}, - {"NetworkError", &NetworkError{Cause: cause}}, - {"SecurityPolicyError", &SecurityPolicyError{Cause: cause}}, - {"InternalError", &InternalError{Cause: cause}}, + {"ValidationError", &errs.ValidationError{Cause: cause}}, + {"AuthenticationError", &errs.AuthenticationError{Cause: cause}}, + {"ConfigError", &errs.ConfigError{Cause: cause}}, + {"NetworkError", &errs.NetworkError{Cause: cause}}, + {"SecurityPolicyError", &errs.SecurityPolicyError{Cause: cause}}, + {"InternalError", &errs.InternalError{Cause: cause}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -152,3 +194,387 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) { }) } } + +// =============================== Builder API =============================== + +// TestNewXxxError_LocksCategory verifies each constructor sets Category +// from its function name; caller cannot mis-specify it. +func TestNewXxxError_LocksCategory(t *testing.T) { + cases := []struct { + name string + got errs.Category + want errs.Category + }{ + {"validation", errs.NewValidationError(errs.SubtypeInvalidArgument, "x").Category, errs.CategoryValidation}, + {"authentication", errs.NewAuthenticationError(errs.SubtypeTokenMissing, "x").Category, errs.CategoryAuthentication}, + {"authorization", errs.NewPermissionError(errs.SubtypeMissingScope, "x").Category, errs.CategoryAuthorization}, + {"config", errs.NewConfigError(errs.SubtypeNotConfigured, "x").Category, errs.CategoryConfig}, + {"network", errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").Category, errs.CategoryNetwork}, + {"api", errs.NewAPIError(errs.SubtypeRateLimit, "x").Category, errs.CategoryAPI}, + {"policy_security", errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "x").Category, errs.CategoryPolicy}, + {"policy_content", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").Category, errs.CategoryPolicy}, + {"internal", errs.NewInternalError(errs.SubtypeSDKError, "x").Category, errs.CategoryInternal}, + {"confirmation", errs.NewConfirmationRequiredError("write", "delete files", "x").Category, errs.CategoryConfirmation}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("Category = %q, want %q", tc.got, tc.want) + } + }) + } +} + +// TestNewXxxError_PrintfFormat verifies Message is formatted via fmt.Sprintf +// just like fmt.Errorf — the canonical Go convention for error messages. +func TestNewXxxError_PrintfFormat(t *testing.T) { + cause := errors.New("boom") + got := errs.NewValidationError(errs.SubtypeInvalidArgument, + "invalid --start (%s): %v", "yesterday", cause) + want := "invalid --start (yesterday): boom" + if got.Message != want { + t.Errorf("Message = %q, want %q", got.Message, want) + } +} + +// TestNewXxxError_LiteralPercentNoArgs pins the constructor's empty-args +// fast path: a literal "%" in the message must NOT be rendered as +// "%!(NOVERB)" when no args are passed. +func TestNewXxxError_LiteralPercentNoArgs(t *testing.T) { + got := errs.NewValidationError(errs.SubtypeInvalidArgument, "disk 100% full") + if got.Message != "disk 100% full" { + t.Errorf("Message = %q, want %q", got.Message, "disk 100% full") + } + hinted := errs.NewInternalError(errs.SubtypeStorage, "save failed"). + WithHint("only 5% headroom remains") + if hinted.Hint != "only 5% headroom remains" { + t.Errorf("Hint = %q, want %q", hinted.Hint, "only 5% headroom remains") + } +} + +// TestWithChain_ReturnsConcretePointer verifies WithX setters return the +// concrete *XxxError pointer, not *Problem — so chains preserve type and +// type-specific setters remain reachable to the end of the chain. +func TestWithChain_ReturnsConcretePointer(t *testing.T) { + // Chain composition: only compiles if every intermediate result has + // the concrete pointer type. Hint is on every type, Param is only on + // ValidationError — chain must keep ValidationError type to reach it. + got := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg"). + WithHint("hint text"). + WithLogID("log-123"). + WithCode(42). + WithRetryable(). + WithParam("--start"). + WithCause(errors.New("boom")) + + if got.Hint != "hint text" { + t.Errorf("Hint = %q, want %q", got.Hint, "hint text") + } + if got.LogID != "log-123" { + t.Errorf("LogID = %q, want %q", got.LogID, "log-123") + } + if got.Code != 42 { + t.Errorf("Code = %d, want %d", got.Code, 42) + } + if !got.Retryable { + t.Errorf("Retryable = false, want true") + } + if got.Param != "--start" { + t.Errorf("Param = %q, want %q", got.Param, "--start") + } + if got.Cause == nil || got.Cause.Error() != "boom" { + t.Errorf("Cause = %v, want error 'boom'", got.Cause) + } +} + +// TestWithChain_MutatesReceiver verifies WithX returns the same pointer +// (not a copy) — chain edits propagate to the original construction. +func TestWithChain_MutatesReceiver(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg") + returned := e.WithHint("hint") + if returned != e { + t.Errorf("WithHint returned different pointer; want same as receiver") + } + if e.Hint != "hint" { + t.Errorf("Receiver Hint not mutated: got %q", e.Hint) + } +} + +// TestWithHint_PrintfFormat verifies WithHint follows fmt.Sprintf, matching +// the constructor's printf convention. +func TestWithHint_PrintfFormat(t *testing.T) { + got := errs.NewValidationError(errs.SubtypeInvalidArgument, "x"). + WithHint("expected one of: %v", []string{"7d", "1m"}) + want := "expected one of: [7d 1m]" + if got.Hint != want { + t.Errorf("Hint = %q, want %q", got.Hint, want) + } +} + +// TestPermissionError_FullChain verifies the most field-heavy typed error +// constructs cleanly via the chain. +func TestPermissionError_FullChain(t *testing.T) { + got := errs.NewPermissionError(errs.SubtypeMissingScope, + "--confirm-send requires scope: %s", "mail:user_mailbox.message:send"). + WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send"). + WithMissingScopes("mail:user_mailbox.message:send"). + WithIdentity("user"). + WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + + if got.Category != errs.CategoryAuthorization { + t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization) + } + if got.Subtype != errs.SubtypeMissingScope { + t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeMissingScope) + } + if len(got.MissingScopes) != 1 || got.MissingScopes[0] != "mail:user_mailbox.message:send" { + t.Errorf("MissingScopes = %v, want [mail:user_mailbox.message:send]", got.MissingScopes) + } + if got.Identity != "user" { + t.Errorf("Identity = %q, want %q", got.Identity, "user") + } + if got.ConsoleURL == "" { + t.Error("ConsoleURL is empty") + } +} + +// TestWithMissingScopes_VariadicAndSliceExpansion verifies both forms work. +func TestWithMissingScopes_VariadicAndSliceExpansion(t *testing.T) { + t.Run("variadic", func(t *testing.T) { + got := errs.NewPermissionError(errs.SubtypeMissingScope, "x"). + WithMissingScopes("a:read", "b:write") + if len(got.MissingScopes) != 2 { + t.Errorf("got %v, want 2 elements", got.MissingScopes) + } + }) + t.Run("slice_expanded", func(t *testing.T) { + scopes := []string{"a:read", "b:write"} + got := errs.NewPermissionError(errs.SubtypeMissingScope, "x"). + WithMissingScopes(scopes...) + if len(got.MissingScopes) != 2 { + t.Errorf("got %v, want 2 elements", got.MissingScopes) + } + }) +} + +// TestNetworkError_SubtypeAndChain verifies that a network failure carries +// its canonical subtype, Retryable flag, and Unwrap chain together. +func TestNetworkError_SubtypeAndChain(t *testing.T) { + got := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "download failed: %v", errors.New("timeout")). + WithCause(errors.New("context deadline exceeded")). + WithRetryable() + + if got.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeNetworkTimeout) + } + if !got.Retryable { + t.Errorf("Retryable = false, want true") + } + if got.Cause == nil { + t.Error("Cause is nil") + } +} + +// TestNewConfirmationRequiredError_RequiresRiskAndAction verifies the +// constructor signature pins Risk + Action as positional args (non-omitempty +// wire fields per types.go). +func TestNewConfirmationRequiredError_RequiresRiskAndAction(t *testing.T) { + got := errs.NewConfirmationRequiredError("high-risk-write", "delete 42 files", + "this operation will delete %d files", 42) + + if got.Risk != "high-risk-write" { + t.Errorf("Risk = %q, want %q", got.Risk, "high-risk-write") + } + if got.Action != "delete 42 files" { + t.Errorf("Action = %q, want %q", got.Action, "delete 42 files") + } + if got.Message != "this operation will delete 42 files" { + t.Errorf("Message = %q", got.Message) + } +} + +// TestBuilder_ErrorsAsCompat verifies builder-constructed errors satisfy +// errors.As / errors.Is for both the typed wrapper and any wrapped cause. +func TestBuilder_ErrorsAsCompat(t *testing.T) { + cause := errors.New("upstream failure") + wrapped := errs.NewInternalError(errs.SubtypeSDKError, "wrap: %v", cause).WithCause(cause) + + var asInternal *errs.InternalError + if !errors.As(wrapped, &asInternal) { + t.Error("errors.As should resolve to *InternalError") + } + if !errors.Is(wrapped, cause) { + t.Error("errors.Is should resolve to original cause via Unwrap") + } +} + +// TestBuilder_WireFormat marshals a fully-built error and asserts the JSON +// matches the canonical envelope shape. This complements marshal_test.go; +// the focus here is verifying builder-set fields land in the right JSON +// keys. +func TestBuilder_WireFormat(t *testing.T) { + e := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope %s", "calendar:event:create"). + WithCode(99991679). + WithLogID("20260520-0a1b2c3d"). + WithHint("run lark-cli auth login --scope calendar:event:create"). + WithMissingScopes("calendar:event:create"). + WithIdentity("user"). + WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + + buf, err := json.Marshal(e) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(buf, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + wantFields := map[string]any{ + "type": "authorization", + "subtype": "missing_scope", + "code": float64(99991679), + "message": "missing scope calendar:event:create", + "hint": "run lark-cli auth login --scope calendar:event:create", + "log_id": "20260520-0a1b2c3d", + "identity": "user", + "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + "missing_scopes": []any{"calendar:event:create"}, + } + for k, want := range wantFields { + gotVal, ok := got[k] + if !ok { + t.Errorf("missing wire field %q in %v", k, got) + continue + } + switch v := want.(type) { + case []any: + gotSlice, ok := gotVal.([]any) + if !ok || len(gotSlice) != len(v) { + t.Errorf("field %q = %v, want %v", k, gotVal, v) + continue + } + for i := range v { + if gotSlice[i] != v[i] { + t.Errorf("field %q[%d] = %v, want %v", k, i, gotSlice[i], v[i]) + } + } + default: + if gotVal != want { + t.Errorf("field %q = %v, want %v", k, gotVal, want) + } + } + } + + // retryable not set → must be absent (omitempty) + if _, present := got["retryable"]; present { + t.Errorf("retryable should be omitted when false, got %v", got["retryable"]) + } +} + +// TestBuilder_WithRetryable_OmittedWhenFalse verifies omitempty behaviour: +// retryable only appears on the wire when explicitly set to true. +func TestBuilder_WithRetryable_OmittedWhenFalse(t *testing.T) { + t.Run("absent_when_not_set", func(t *testing.T) { + e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x") + buf, _ := json.Marshal(e) + var got map[string]any + _ = json.Unmarshal(buf, &got) + if _, ok := got["retryable"]; ok { + t.Errorf("retryable present when unset; want omitted") + } + }) + t.Run("present_when_set", func(t *testing.T) { + e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").WithRetryable() + buf, _ := json.Marshal(e) + var got map[string]any + _ = json.Unmarshal(buf, &got) + v, ok := got["retryable"] + if !ok || v != true { + t.Errorf("retryable = %v ok=%v, want true present", v, ok) + } + }) +} + +// TestNewSecurityPolicyError_ChallengeURL covers the Policy-specific field. +func TestNewSecurityPolicyError_ChallengeURL(t *testing.T) { + got := errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "verify your device"). + WithCode(21000). + WithChallengeURL("https://applink.feishu.cn/T/xxxxx") + if got.ChallengeURL == "" { + t.Error("ChallengeURL not set") + } + if got.Code != 21000 { + t.Errorf("Code = %d, want 21000", got.Code) + } +} + +// TestNewContentSafetyError_Rules covers the variadic Rules setter. +func TestNewContentSafetyError_Rules(t *testing.T) { + got := errs.NewContentSafetyError(errs.SubtypeUnknown, "content blocked"). + WithRules("no_pii", "no_secrets") + if len(got.Rules) != 2 { + t.Errorf("Rules = %v, want 2 elements", got.Rules) + } +} + +// TestTypedError_UnwrapSymmetry pins that every typed error carries a Cause +// field that participates in errors.Unwrap / errors.Is. Uniformity across +// all typed errors lets callers descend below the typed-error boundary +// without first switching on the concrete type. +func TestTypedError_UnwrapSymmetry(t *testing.T) { + sentinel := errors.New("upstream cause") + cases := []struct { + name string + err error + }{ + {"APIError", errs.NewAPIError(errs.SubtypeServerError, "x").WithCause(sentinel)}, + {"PermissionError", errs.NewPermissionError(errs.SubtypeMissingScope, "x").WithCause(sentinel)}, + {"ContentSafetyError", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").WithCause(sentinel)}, + {"ConfirmationRequiredError", errs.NewConfirmationRequiredError("write", "cmd", "x").WithCause(sentinel)}, + } + for _, tc := range cases { + t.Run(tc.name+"_Unwrap_returns_cause", func(t *testing.T) { + if got := errors.Unwrap(tc.err); got != sentinel { + t.Errorf("Unwrap() = %v, want %v", got, sentinel) + } + }) + t.Run(tc.name+"_errors.Is_sentinel", func(t *testing.T) { + if !errors.Is(tc.err, sentinel) { + t.Error("errors.Is(err, sentinel) = false, want true via Unwrap chain") + } + }) + } + t.Run("nil_receiver_Unwrap_safe", func(t *testing.T) { + var p *errs.APIError + _ = p.Unwrap() + var pp *errs.PermissionError + _ = pp.Unwrap() + var c *errs.ContentSafetyError + _ = c.Unwrap() + var cr *errs.ConfirmationRequiredError + _ = cr.Unwrap() + }) +} + +func TestBuilderSetter_DefensiveCopy(t *testing.T) { + t.Run("WithMissingScopes clones input", func(t *testing.T) { + scopes := []string{"docx:document", "im:message:send"} + err := errs.NewPermissionError(errs.SubtypeMissingScope, "test"). + WithMissingScopes(scopes...) + scopes[0] = "MUTATED" + if got := err.MissingScopes[0]; got != "docx:document" { + t.Errorf("MissingScopes[0] = %q after caller mutation; want defensive copy", got) + } + }) + t.Run("WithRules clones input", func(t *testing.T) { + rules := []string{"rule-A", "rule-B"} + err := errs.NewContentSafetyError(errs.SubtypeUnknown, "test"). + WithRules(rules...) + rules[0] = "MUTATED" + if got := err.Rules[0]; got != "rule-A" { + t.Errorf("Rules[0] = %q after caller mutation; want defensive copy", got) + } + }) +} diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go index a4660964..0be89789 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -214,7 +214,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored } var data map[string]interface{} if err := json.Unmarshal(body, &data); err != nil { - return nil, fmt.Errorf("token refresh parse error: %v", err) + return nil, fmt.Errorf("token refresh parse error: %w", err) } return data, nil } diff --git a/internal/auth/verify.go b/internal/auth/verify.go index 403ad448..8786a7d3 100644 --- a/internal/auth/verify.go +++ b/internal/auth/verify.go @@ -31,7 +31,7 @@ func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) Msg string `json:"msg"` } if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { - return fmt.Errorf("failed to parse response: %v", err) + return fmt.Errorf("failed to parse response: %w", err) } if resp.Code != 0 { return fmt.Errorf("[%d] %s", resp.Code, resp.Msg) diff --git a/internal/client/api_errors.go b/internal/client/api_errors.go index 613b02da..6b924414 100644 --- a/internal/client/api_errors.go +++ b/internal/client/api_errors.go @@ -5,91 +5,130 @@ package client import ( "bytes" + "crypto/x509" "encoding/json" "errors" - "fmt" - "io" + "net" "strings" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/output" ) +// rawAPIJSONHint guides users when an SDK or response body parse fails. The +// most common cause is a non-JSON payload (file download endpoint hit without +// `--output`, or an upstream HTML error page). const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output." -// 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. +// WrapDoAPIError converts SDK-boundary failures into typed errs.* errors: +// already-typed errors pass through (idempotent), JSON-decode failures +// become InternalError{SubtypeInvalidResponse}, everything else becomes +// NetworkError with a chain-derived subtype (timeout / tls / dns / +// server_error / transport-fallback). func WrapDoAPIError(err error) error { if err == nil { return nil } - var existing *output.ExitError - if errors.As(err, &existing) { - return err - } + + // (1) Pass-through any typed errs.* error. 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) + + // (2) JSON-decode failure at the SDK boundary → InternalError. + if isJSONDecodeError(err) { + return errs.NewInternalError(errs.SubtypeInvalidResponse, + "SDK returned an invalid JSON response: %v", err). + WithHint("%s", rawAPIJSONHint). + WithCause(err) } - return output.ErrNetwork("API call failed: %v", err) + + // (3) Otherwise classify as a network failure with a chain-derived subtype. + return errs.NewNetworkError(classifyNetworkSubtype(err), + "API call failed: %v", err). + WithCause(err) } -// 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. +// WrapJSONResponseParseError lifts a response-layer JSON parse failure into +// *errs.InternalError{Subtype: SubtypeInvalidResponse}. Empty body, malformed +// JSON, and mid-stream EOFs all collapse to this single shape. func WrapJSONResponseParseError(err error, body []byte) error { if err == nil { return nil } + + var e *errs.InternalError if len(bytes.TrimSpace(body)) == 0 { - return output.ErrWithHint(output.ExitAPI, "api_error", - "API returned an empty JSON response body", rawAPIJSONHint) + e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an empty JSON response body") + } else { + e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err) } - if isJSONDecodeError(err, true) { - return output.ErrWithHint(output.ExitAPI, "api_error", - fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint) - } - return output.ErrNetwork("API call failed: %v", err) + return e.WithHint("%s", rawAPIJSONHint).WithCause(err) } -func isJSONDecodeError(err error, allowEOF bool) bool { +// classifyNetworkSubtype maps an error chain to one of the network subtypes, +// falling back to SubtypeNetworkTransport. Timeout is checked first because +// a net.OpError can satisfy net.Error and also wrap a DNS sub-error in +// pathological proxy configurations — we prefer the timeout signal. +func classifyNetworkSubtype(err error) errs.Subtype { + // (a) Timeout — net.Error.Timeout(), plus the SDK's typed timeout + // errors (which do not implement net.Error). + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return errs.SubtypeNetworkTimeout + } + var sdkServerTimeout *larkcore.ServerTimeoutError + if errors.As(err, &sdkServerTimeout) { + return errs.SubtypeNetworkTimeout + } + var sdkClientTimeout *larkcore.ClientTimeoutError + if errors.As(err, &sdkClientTimeout) { + return errs.SubtypeNetworkTimeout + } + + // (b) TLS — typed x509 error or message substring fallback. + var x509Err *x509.UnknownAuthorityError + if errors.As(err, &x509Err) { + return errs.SubtypeNetworkTLS + } + msg := err.Error() + if strings.Contains(msg, "x509:") || strings.Contains(msg, "tls:") { + return errs.SubtypeNetworkTLS + } + + // (c) DNS — *net.DNSError covers SDK chains coming from net.Dialer. + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return errs.SubtypeNetworkDNS + } + + // HTTP 5xx classification lives on the call sites with *http.Response + // access (DoStream, HandleResponse); the SDK never surfaces non-504 5xx + // as an error here. + return errs.SubtypeNetworkTransport +} + +// isJSONDecodeError reports whether err is a JSON decode failure at the +// SDK boundary, matching both typed json errors and their fmt.Errorf- +// wrapped substring form. io.EOF is intentionally excluded — at the SDK +// boundary an EOF is a transport failure, not a payload-shape failure. +func isJSONDecodeError(err error) bool { var syntaxErr *json.SyntaxError var unmarshalTypeErr *json.UnmarshalTypeError - if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) { return true } - if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) { - return true - } + // Substring fallback for fmt.Errorf-wrapped json decode errors that no + // longer satisfy errors.As against the typed json errors. "invalid + // character" alone is too broad (other libraries surface it for non- + // JSON failures), so it is gated on the message also containing "json". msg := err.Error() - if allowEOF && strings.Contains(msg, "unexpected EOF") { + if strings.Contains(msg, "unexpected end of JSON input") || + strings.Contains(msg, "cannot unmarshal") { return true } - return strings.Contains(msg, "unexpected end of JSON input") || - strings.Contains(msg, "invalid character") || - strings.Contains(msg, "cannot unmarshal") + lower := strings.ToLower(msg) + return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json") } diff --git a/internal/client/api_errors_test.go b/internal/client/api_errors_test.go index 4a3cf5f1..65845adc 100644 --- a/internal/client/api_errors_test.go +++ b/internal/client/api_errors_test.go @@ -4,173 +4,312 @@ package client import ( + "crypto/x509" "encoding/json" "errors" "fmt" "io" + "net" "strings" "testing" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" ) -func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) { - err := WrapDoAPIError(&json.SyntaxError{Offset: 1}) - if err == nil { - t.Fatal("expected error") - } +// ───────────────────────────────────────────────────────────────────────────── +// WrapDoAPIError: typed error contract. +// +// Pass-through: any error carrying *errs.Problem (detected via ProblemOf). +// JSON decode failures → *errs.InternalError{Subtype: invalid_response}. +// Otherwise → *errs.NetworkError with one of: timeout / tls / dns / +// server_error / transport (fallback). +// ───────────────────────────────────────────────────────────────────────────── - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %T", err) +// timeoutNetError implements net.Error with Timeout() == true. Used to exercise +// the timeout branch of the network classifier without depending on a live +// transport. +type timeoutNetError struct{} + +func (timeoutNetError) Error() string { return "i/o timeout" } +func (timeoutNetError) Timeout() bool { return true } +func (timeoutNetError) Temporary() bool { return true } + +// TestWrapDoAPIError_SyntaxError_ReturnsInternalError pins that a raw +// *json.SyntaxError from the SDK boundary surfaces as an *errs.InternalError +// with Subtype=invalid_response — replacing the legacy api_error envelope. +func TestWrapDoAPIError_SyntaxError_ReturnsInternalError(t *testing.T) { + got := WrapDoAPIError(&json.SyntaxError{Offset: 1}) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T (%v)", got, got) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI, got %d", exitErr.Code) + if ie.Category != errs.CategoryInternal { + t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal) } - if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") { - t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail) + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse) } } -func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) { - err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{")) - if err == nil { - t.Fatal("expected error") +// TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError pins the second +// json-decode error variant (type-mismatch decoding) routes through the same +// invalid_response branch — not the network fallback. +func TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError(t *testing.T) { + got := WrapDoAPIError(&json.UnmarshalTypeError{Value: "string", Type: nil}) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T", got) } - - 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 || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") { - t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail) + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse) } } -// 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) - } +// TestWrapDoAPIError_Timeout pins that an SDK transport error whose chain +// carries a net.Error with Timeout()==true classifies as +// NetworkError{Subtype: timeout}. Covers the E2E timeout scenario +// (HTTPS_PROXY pointing at a non-routable address). +func TestWrapDoAPIError_Timeout(t *testing.T) { + got := WrapDoAPIError(&net.OpError{Op: "dial", Net: "tcp", Err: timeoutNetError{}}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T (%v)", got, got) + } + if ne.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout) + } + if ne.Category != errs.CategoryNetwork { + t.Errorf("Category = %v, want %v", ne.Category, errs.CategoryNetwork) } } -// 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) +// TestWrapDoAPIError_TLS pins that an x509.UnknownAuthorityError classifies +// as NetworkError{Subtype: tls}. +func TestWrapDoAPIError_TLS(t *testing.T) { + got := WrapDoAPIError(&x509.UnknownAuthorityError{}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) } - 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) + if ne.Subtype != errs.SubtypeNetworkTLS { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS) } } -// 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"}, +// TestWrapDoAPIError_TLS_HandshakeMessage covers the message-substring fallback +// for TLS errors that don't surface as a typed x509 error. +func TestWrapDoAPIError_TLS_HandshakeMessage(t *testing.T) { + got := WrapDoAPIError(errors.New("remote error: tls: handshake failure")) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) } - 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 "" - } - return exitErr.Detail.Type - }(), - tc.wantType, exitErr.Detail) - } - }) + if ne.Subtype != errs.SubtypeNetworkTLS { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS) } } -// 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) { +// TestWrapDoAPIError_DNS pins that a *net.DNSError classifies as +// NetworkError{Subtype: dns}. +func TestWrapDoAPIError_DNS(t *testing.T) { + got := WrapDoAPIError(&net.DNSError{Name: "example.invalid"}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkDNS { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkDNS) + } +} + +// TestWrapDoAPIError_SDKServerTimeout pins that a *larkcore.ServerTimeoutError +// (504 Gateway Timeout surfaced by the SDK as a typed error rather than an +// *http.Response) classifies as timeout — upstream took too long to respond. +func TestWrapDoAPIError_SDKServerTimeout(t *testing.T) { + got := WrapDoAPIError(&larkcore.ServerTimeoutError{}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout) + } +} + +// TestWrapDoAPIError_SDKClientTimeout pins that a *larkcore.ClientTimeoutError +// (client-side request timeout the SDK reports without satisfying net.Error) +// classifies as timeout. +func TestWrapDoAPIError_SDKClientTimeout(t *testing.T) { + got := WrapDoAPIError(&larkcore.ClientTimeoutError{}) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTimeout { + t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout) + } +} + +// TestWrapDoAPIError_UnknownCause_FallsBackToTransport pins the fallback: +// when none of the specific causes match, NetworkError uses the generic +// transport subtype. +func TestWrapDoAPIError_UnknownCause_FallsBackToTransport(t *testing.T) { + got := WrapDoAPIError(errors.New("connection reset by peer")) + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("Subtype = %v, want %v (fallback)", ne.Subtype, errs.SubtypeNetworkTransport) + } +} + +// TestWrapDoAPIError_PassThrough_TypedError pins that any typed *errs.* error +// (carrying an embedded Problem) passes through unchanged — same pointer +// identity, no re-classification. This is the load-bearing invariant for +// resolveAccessToken returning *errs.AuthenticationError through DoSDKRequest. +func TestWrapDoAPIError_PassThrough_TypedError(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}}, + &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing, Message: "no token"}}, + &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Message: "no scope"}}, + &errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport, Message: "transport"}}, + &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, Message: "sdk"}}, } 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) + t.Fatalf("expected identity pass-through, 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) +// TestWrapDoAPIError_Nil pins that nil in stays nil out (no allocation, no +// panic). Callers rely on this when the SDK returns success. +func TestWrapDoAPIError_Nil(t *testing.T) { + if got := WrapDoAPIError(nil); got != nil { + t.Errorf("WrapDoAPIError(nil) = %v, want nil", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// WrapJSONResponseParseError: typed error contract. +// +// All response-layer parse failures (empty body, malformed JSON, mid-stream +// read failures that surface as parse errors) collapse to a single +// *errs.InternalError{Subtype: invalid_response}. The rawAPIJSONHint is +// preserved on Problem.Hint so users still get the "may have returned an +// empty or non-standard body, rerun with --output" guidance. +// ───────────────────────────────────────────────────────────────────────────── + +// TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError pins the +// new shape for malformed JSON bodies — replaces the legacy api_error path. +func TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError(t *testing.T) { + got := WrapJSONResponseParseError(&json.SyntaxError{Offset: 1}, []byte("{ malformed")) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T", got) + } + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse) + } + if ie.Hint != rawAPIJSONHint { + t.Errorf("Hint = %q, want rawAPIJSONHint preserved", ie.Hint) + } +} + +// TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError pins that +// empty / whitespace-only response bodies also surface as invalid_response, +// not as a network error. Endpoints returning only "\n" or "" trigger this. +func TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError(t *testing.T) { + for _, body := range [][]byte{nil, {}, []byte(" \t\n")} { + got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("body=%q: expected *errs.InternalError, got %T", body, got) + } + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("body=%q: Subtype = %v, want invalid_response", body, ie.Subtype) + } + } +} + +// TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError pins that +// io.ErrUnexpectedEOF mid-decode also surfaces as invalid_response — keeps +// the legacy non-empty-body decode-failure semantics under the new typed +// envelope. +func TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError(t *testing.T) { + got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{")) + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("expected *errs.InternalError, got %T", got) + } + if ie.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want invalid_response", ie.Subtype) + } +} + +// TestWrapJSONResponseParseError_Nil pins nil pass-through. +func TestWrapJSONResponseParseError_Nil(t *testing.T) { + if got := WrapJSONResponseParseError(nil, []byte("anything")); got != nil { + t.Errorf("WrapJSONResponseParseError(nil, ...) = %v, want nil", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cross-cutting: existing tests already in this file (kept and adjusted below). +// ───────────────────────────────────────────────────────────────────────────── + +// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy +// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier +// and is therefore not pass-through — only typed *errs.* values are. +// Legacy values fall through to the network/JSON branches based on their +// inner shape. +func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) { + // An *output.ErrAuth has no embedded Problem and no JSON-decode chain; + // it routes to the network branch with the fallback transport subtype. + got := WrapDoAPIError(output.ErrAuth("no access token available for user")) + + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got) + } + // Sanity: not silently re-classified as JSON-decode. + var ie *errs.InternalError + if errors.As(got, &ie) { + t.Fatalf("expected NetworkError, got InternalError %v", ie) + } +} + +// TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins pins that a typed +// *errs.AuthenticationError wrapping a JSON syntax error in its chain still +// passes through as the outer type — we never re-classify a typed problem +// carrier just because the chain contains a json.SyntaxError. Forward-compat +// for credential chain errors that bundle a parse failure as Cause. +func TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins(t *testing.T) { + jsonErr := &json.SyntaxError{Offset: 1} + outer := &errs.AuthenticationError{ + Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired, Message: "expired"}, + Cause: jsonErr, + } + + got := WrapDoAPIError(outer) + if got != outer { + t.Fatalf("expected outer typed error to win, got %T %v", got, got) + } +} + +// TestWrapDoAPIError_MessageContainsCause pins that the wrapped error's +// message is carried into Problem.Message so logs / debugging retain the +// underlying cause string. +func TestWrapDoAPIError_MessageContainsCause(t *testing.T) { + raw := errors.New("dial tcp 10.0.0.1:443: i/o timeout") + got := WrapDoAPIError(raw) + if !strings.Contains(got.Error(), "i/o timeout") { + t.Errorf("Error() = %q, want to contain underlying cause", got.Error()) } } diff --git a/internal/client/client.go b/internal/client/client.go index a307b8ef..dc4a0e89 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -18,8 +18,12 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ) @@ -48,16 +52,38 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s if err != nil { var unavailableErr *credential.TokenUnavailableError if errors.As(err, &unavailableErr) { - return "", output.ErrAuth("no access token available for %s", as) + return "", newTokenMissingError(as, unavailableErr) + } + // NeedAuthorizationError from the credential chain (e.g. UAT refresh + // returned need_user_authorization) must surface as typed + // AuthenticationError. Without this, WrapDoAPIError would wrap the + // raw err as NetworkError, and cmd/root.go's outer-typed gate would + // then skip PromoteAuthError — leaving the user with exit 4 and no + // auth-login hint instead of exit 3 typed authentication. + var needAuthErr *internalauth.NeedAuthorizationError + if errors.As(err, &needAuthErr) { + return "", errcompat.PromoteAuthError(needAuthErr) } return "", err } if result.Token == "" { - return "", output.ErrAuth("no access token available for %s", as) + return "", newTokenMissingError(as, nil) } return result.Token, nil } +// newTokenMissingError builds the typed *errs.AuthenticationError that +// resolveAccessToken returns when no usable token is available for the +// requested identity. cause is the underlying credential-chain error (or nil +// for the defensive empty-token branch) and is preserved for errors.Is / +// errors.Unwrap traversal without being serialized on the wire. +func newTokenMissingError(as core.Identity, cause error) error { + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, + "no access token available for %s", as). + WithHint("run: lark-cli auth login to re-authorize"). + WithCause(cause) +} + // buildApiReq converts a RawApiRequest into SDK types and collects // request-specific options (ExtraOpts, URL-based headers). // Auth is handled separately by DoSDKRequest. @@ -93,14 +119,14 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark // 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 +// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without +// each one remembering to wrap. Today that wire shape is still the legacy +// *output.ExitError envelope (network / api_error); future framework- // boundary migration flips WrapDoAPIError to typed *errs.NetworkError / // *errs.InternalError per the contract in errs/ERROR_CONTRACT.md. // Errors that arrive already-classified (legacy *output.ExitError from -// resolveAccessToken's missing-credential paths, or a typed *errs.* from -// future stages) flow through unchanged. +// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow +// through unchanged. func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { var opts []larkcore.RequestOptionFunc @@ -177,7 +203,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core. httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader) if err != nil { cancel() - return nil, output.ErrNetwork("stream request failed: %s", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "stream request failed: %s", err).WithCause(err) } // Apply headers from opts @@ -195,7 +221,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core. resp, err := httpClient.Do(httpReq) if err != nil { cancel() - return nil, output.ErrNetwork("stream request failed: %s", err) + return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err) } resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel} @@ -204,31 +230,32 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core. defer resp.Body.Close() errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) msg := strings.TrimSpace(string(errBody)) - if msg != "" { - err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg) - attachStreamLogID(err, resp.Header) - return nil, err + subtype := errs.SubtypeNetworkTransport + if resp.StatusCode >= 500 { + subtype = errs.SubtypeNetworkServer } - err := output.ErrNetwork("HTTP %d", resp.StatusCode) - attachStreamLogID(err, resp.Header) - return nil, err + var netErr *errs.NetworkError + if msg != "" { + netErr = errs.NewNetworkError(subtype, "HTTP %d: %s", resp.StatusCode, msg) + } else { + netErr = errs.NewNetworkError(subtype, "HTTP %d", resp.StatusCode) + } + netErr = netErr.WithCode(resp.StatusCode) + if logID := streamLogID(resp.Header); logID != "" { + netErr = netErr.WithLogID(logID) + } + return nil, netErr } return resp, nil } -func attachStreamLogID(err *output.ExitError, header http.Header) { - if err == nil || err.Detail == nil { - return - } +func streamLogID(header http.Header) string { logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId)) if logID == "" { logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId)) } - if logID == "" { - return - } - err.Detail.Detail = map[string]any{"log_id": logID} + return logID } type cancelOnCloseBody struct { @@ -256,10 +283,10 @@ func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) pathKey := strings.TrimPrefix(segment, ":") pathValue, ok := req.PathParams[pathKey] if !ok { - return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey) } if pathValue == "" { - return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "empty path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey) } pathSegs = append(pathSegs, url.PathEscape(pathValue)) } @@ -285,7 +312,7 @@ func buildStreamBody(body interface{}) (io.Reader, string, error) { default: payload, err := json.Marshal(typed) if err != nil { - return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err) + return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "failed to encode request body: %s", err).WithCause(err) } return bytes.NewReader(payload), "application/json", nil } @@ -306,11 +333,9 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore // JSON parse failures are wrapped via WrapJSONResponseParseError so callers // (notably the pagination loop and --page-all paths in cmd/api / cmd/service) // see an *output.ExitError envelope (api_error for malformed JSON, network -// for everything else) instead of a bare fmt.Errorf. Without this, an empty +// for everything else) instead of a bare fmt.Errorf — otherwise an empty // or malformed page body would surface to the root handler as a plain-text -// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4 -// framework-boundary migration will flip this wrapper to typed -// *errs.InternalError / *errs.NetworkError. +// "Error: ..." line and bypass the JSON stderr envelope contract. func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) { resp, err := c.DoAPI(ctx, request) if err != nil { @@ -464,23 +489,23 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil } -// 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. +// CheckResponse inspects a Lark API response for business-level errors (non-zero code) +// and routes the result through errclass.BuildAPIError so the wire envelope carries +// the canonical Category/Subtype + identity-aware extension fields (MissingScopes, +// ConsoleURL, etc.) for known Lark codes; unknown codes still surface as +// *errs.APIError{Subtype: unknown}. func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error { resultMap, ok := result.(map[string]interface{}) if !ok || resultMap == nil { return nil } - code, _ := util.ToFloat64(resultMap["code"]) - if code == 0 { + if code, _ := util.ToFloat64(resultMap["code"]); code == 0 { return nil } - larkCode := int(code) - msg, _ := resultMap["msg"].(string) - return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) + cc := errclass.ClassifyContext{Identity: string(identity)} + if c != nil && c.Config != nil { + cc.Brand = string(c.Config.Brand) + cc.AppID = c.Config.AppID + } + return errclass.BuildAPIError(resultMap, cc) } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index a1330c4a..8cf38d95 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -18,6 +19,8 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" @@ -428,6 +431,39 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) { } } +// TestDoStream_TransportFailureSplitsSubtype pins that a streaming-request +// transport failure routes through classifyNetworkSubtype rather than emitting +// a hardcoded SubtypeNetworkTransport for every cause. Concretely: a DNS +// failure must surface as SubtypeNetworkDNS so downstream agents can react +// (retry / give up / show recovery hint) without parsing the message text. +// Pre-fix, DoStream collapsed every httpClient.Do failure to NetworkTransport, +// erasing the timeout / TLS / DNS distinctions the SDK path already preserved. +func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) { + rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { + return nil, &net.DNSError{Err: "no such host", Name: "nowhere.invalid"} + }) + ac := &APIClient{ + HTTP: &http.Client{Transport: rt}, + Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil), + Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + } + + _, err := ac.DoStream(context.Background(), &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/drive/v1/files/file_token/download", + }, core.AsBot) + if err == nil { + t.Fatal("expected DNS error from DoStream transport, got nil") + } + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err) + } + if netErr.Subtype != errs.SubtypeNetworkDNS { + t.Errorf("Subtype = %q, want %q (DNS failures must not be classified as generic transport)", netErr.Subtype, errs.SubtypeNetworkDNS) + } +} + // failingTokenResolver always returns TokenUnavailableError, exercising the // auth/credential failure path through resolveAccessToken. type failingTokenResolver struct{} @@ -436,17 +472,93 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type} } -// 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 "), -// DoSDKRequest must surface it with the original auth classification — -// not silently downgrade it to a network error via the SDK-failure wrap. +// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that +// the missing-token path of resolveAccessToken returns the typed +// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy +// *output.ExitError envelope. +func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) { + ac := &APIClient{ + HTTP: &http.Client{}, + Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil), + Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + } + + _, err := ac.resolveAccessToken(context.Background(), core.AsUser) + if err == nil { + t.Fatal("expected error when no token available, got nil") + } + + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err) + } + if authErr.Category != errs.CategoryAuthentication { + t.Errorf("Category = %v, want %v", authErr.Category, errs.CategoryAuthentication) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } +} + +// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to +// exercise the P1 regression path: a credential chain that signals +// "user must re-authorize" must surface as typed AuthenticationError, not +// fall through to the generic err return which WrapDoAPIError would then +// wrap as NetworkError (the outer-typed dispatcher gate would then skip +// PromoteAuthError and the user would see exit 4 with no auth-login hint). +type needAuthTokenResolver struct { + userOpenID string +} + +func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) { + return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID} +} + +// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication +// is the codex P1 regression test: without this branch, the credential +// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError +// would mis-classify it as NetworkError. +func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) { + ac := &APIClient{ + HTTP: &http.Client{}, + Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil), + Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}, + } + + _, err := ac.resolveAccessToken(context.Background(), core.AsUser) + if err == nil { + t.Fatal("expected error when credential chain signals need_user_authorization, got nil") + } + + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } + if !strings.Contains(authErr.Message, "need_user_authorization") { + t.Errorf("Message must contain the marker 'need_user_authorization' (invariant), got %q", authErr.Message) + } + // Underlying NeedAuthorizationError preserved in Cause chain so + // existing errors.As(&NeedAuthorizationError{}) consumers still match. + var needErr *internalauth.NeedAuthorizationError + if !errors.As(err, &needErr) { + t.Errorf("NeedAuthorizationError not preserved in Cause chain") + } +} + +// TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError pins the +// end-to-end invariant codex caught the day this PR landed: when +// resolveAccessToken fails because no token is cached, DoSDKRequest must +// surface that as a typed *errs.AuthenticationError — 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) { +func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) { ac := &APIClient{ HTTP: &http.Client{}, Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil), @@ -461,22 +573,20 @@ func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) { if err == nil { t.Fatal("expected auth error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T (%v) — WrapDoAPIError must pass typed *errs.* through unchanged", err, 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) + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) } } // TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped -// SDK transport errors get the network classification via WrapDoAPIError. +// SDK transport errors get the typed 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. +// *url.Error, which the wrap classifier reaches as the transport-error +// fallback (no specific subtype matches — falls back to transport). func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) { rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { return nil, io.ErrUnexpectedEOF @@ -491,25 +601,29 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) { 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) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err) } - if exitErr.Code != output.ExitNetwork { - t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork) + if netErr.Category != errs.CategoryNetwork { + t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork) } - if exitErr.Detail == nil || exitErr.Detail.Type != "network" { - t.Fatalf("Detail.Type = %v, want network", exitErr.Detail) + if netErr.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("Subtype = %v, want %v", netErr.Subtype, errs.SubtypeNetworkTransport) + } + // io.ErrUnexpectedEOF round-tripping through net/http does not satisfy + // any of the specific cause checks; subtype falls back to transport. + if output.ExitCodeOf(err) != output.ExitNetwork { + t.Errorf("ExitCodeOf = %d, want %d (network)", output.ExitCodeOf(err), output.ExitNetwork) } } -// 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. +// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for +// malformed JSON response bodies: WrapJSONResponseParseError emits +// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint +// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see +// the typed JSON stderr envelope (exit 5/internal) — wire `type` is +// "internal", not the legacy "api_error". func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) { rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { return &http.Response{ @@ -529,17 +643,20 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) { 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) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI) + if intErr.Category != errs.CategoryInternal { + t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal) } - if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" { - t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail) + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse) } - if exitErr.Detail.Hint != rawAPIJSONHint { - t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint) + if intErr.Hint != rawAPIJSONHint { + t.Errorf("Hint = %q, want rawAPIJSONHint preserved", intErr.Hint) + } + if output.ExitCodeOf(err) != output.ExitInternal { + t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal) } } diff --git a/internal/client/dostream_test.go b/internal/client/dostream_test.go index a4a1a1d1..0a33868a 100644 --- a/internal/client/dostream_test.go +++ b/internal/client/dostream_test.go @@ -11,10 +11,10 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" ) func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) { @@ -41,12 +41,11 @@ func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) { HttpMethod: http.MethodGet, ApiPath: "/open-apis/drive/v1/medias/file_token/download", }, core.AsBot) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured error, got %T %v", err, err) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError, got %T %v", err, err) } - detail, _ := exitErr.Detail.Detail.(map[string]any) - if detail["log_id"] != "202605270003" { - t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail) + if netErr.LogID != "202605270003" { + t.Fatalf("LogID = %q, want %q", netErr.LogID, "202605270003") } } diff --git a/internal/client/response.go b/internal/client/response.go index 67ff98a0..fbd88f7c 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -14,6 +14,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -52,12 +53,10 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { } check := opts.CheckError if check == nil { - // 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. + // Default check routes through BuildAPIError, producing typed + // *errs.PermissionError / AuthenticationError / etc. A zero-value + // *APIClient is safe here because BuildAPIError gracefully degrades + // identity-aware fields (ConsoleURL etc.) when AppID is empty. check = func(r interface{}, id core.Identity) error { return (&APIClient{}).CheckResponse(r, id) } @@ -65,9 +64,20 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { // Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly // instead of falling through to the binary-save path. + // 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error). if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" { body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500) - return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body) + if resp.StatusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, + "HTTP %d: %s", resp.StatusCode, body). + WithCode(resp.StatusCode) + } + subtype := errs.SubtypeUnknown + if resp.StatusCode == 404 { + subtype = errs.SubtypeNotFound + } + return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body). + WithCode(resp.StatusCode) } // JSON responses: always check for business errors before saving. @@ -102,7 +112,9 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { // Non-JSON (binary) responses. if opts.JqExpr != "" { - return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--jq requires a JSON response (got Content-Type: %s)", ct). + WithParam("--jq") } if opts.OutputPath != "" { return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out) @@ -111,7 +123,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { // No --output: auto-save with derived filename. meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp)) if err != nil { - return output.Errorf(output.ExitInternal, "file_error", "%s", err) + return classifySaveErr(err) } fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct) output.PrintJson(opts.Out, meta) @@ -121,12 +133,23 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error { meta, err := SaveResponse(fio, resp, path) if err != nil { - return output.Errorf(output.ExitInternal, "file_error", "%s", err) + return classifySaveErr(err) } output.PrintJson(w, meta) return nil } +// classifySaveErr routes a SaveResponse error to the right typed shape. +// Path-validation failures are caller-induced (an unsafe --output path), +// so they surface as ValidationError on --output. Mkdir / write failures +// are local I/O issues classified as InternalError with SubtypeFileIO. +func classifySaveErr(err error) error { + if errors.Is(err, fileio.ErrPathValidation) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--output") + } + return errs.NewInternalError(errs.SubtypeFileIO, "save response: %v", err).WithCause(err) +} + // ── JSON helpers ── // IsJSONContentType reports whether the Content-Type header indicates a JSON response. @@ -160,13 +183,13 @@ func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) var we *fileio.WriteError switch { case errors.Is(err, fileio.ErrPathValidation): - return nil, fmt.Errorf("unsafe output path: %s", err) + return nil, fmt.Errorf("unsafe output path: %w", err) case errors.As(err, &me): - return nil, fmt.Errorf("create directory: %s", err) + return nil, fmt.Errorf("create directory: %w", err) case errors.As(err, &we): - return nil, fmt.Errorf("cannot write file: %s", err) + return nil, fmt.Errorf("cannot write file: %w", err) default: - return nil, fmt.Errorf("cannot write file: %s", err) + return nil, fmt.Errorf("cannot write file: %w", err) } } @@ -225,12 +248,3 @@ func mimeToExt(ct string) string { return ".bin" } } - -// httpExitCode maps HTTP status ranges to CLI exit codes: -// 5xx → ExitNetwork (server error), 4xx → ExitAPI (client error). -func httpExitCode(status int) int { - if status >= 500 { - return output.ExitNetwork - } - return output.ExitAPI -} diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 4030d1ce..0902e555 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -15,6 +15,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs/localfileio" ) @@ -294,9 +295,12 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) { if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") { t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { - t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code) + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Errorf("expected *errs.APIError, got %T", err) + } + if output.ExitCodeOf(err) != output.ExitAPI { + t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err)) } } @@ -312,9 +316,12 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) { if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") { t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork { - t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Errorf("expected *errs.NetworkError, got %T", err) + } + if output.ExitCodeOf(err) != output.ExitNetwork { + t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err)) } } diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 5eff1931..827d7e58 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -5,7 +5,6 @@ package cmdutil import ( "context" - "fmt" "io" "net/http" "strings" @@ -13,13 +12,13 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/keychain" - "github.com/larksuite/cli/internal/output" ) // Factory holds shared dependencies injected into every command. @@ -129,11 +128,18 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { } list := strings.Join(supported, ", ") if f.IdentityAutoDetected { - return output.ErrValidation( - "resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s", - as, list, supported[0]) + base := errs.NewValidationError(errs.SubtypeInvalidArgument, + "resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s", + as, list). + WithParam("--as") + if len(supported) > 0 { + return base.WithHint("use --as %s", supported[0]) + } + return base } - return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--as %s is not supported, this command only supports: %s", as, list). + WithParam("--as") } // ResolveStrictMode returns the effective strict mode by reading @@ -161,9 +167,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode { func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error { mode := f.ResolveStrictMode(ctx) if mode.IsActive() && !mode.AllowsIdentity(as) { - return output.ErrWithHint(output.ExitValidation, "command_denied", - fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()), - "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()). + WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } return nil } @@ -202,9 +208,9 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient }, nil } -// RequireBuiltinCredentialProvider returns a structured error (exit 2, code -// "external_provider") when an extension provider is actively managing credentials. -// Intended for use as PersistentPreRunE on the auth and config parent commands. +// RequireBuiltinCredentialProvider returns a typed validation error when an +// extension provider is actively managing credentials. Intended for use as +// PersistentPreRunE on the auth and config parent commands. // // Returns nil when: // - f.Credential is nil (test environments without credential setup) @@ -220,10 +226,7 @@ func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command if provName == "" { return nil } - return output.ErrWithHint( - output.ExitValidation, - "external_provider", - fmt.Sprintf("%q is not supported: credentials are provided externally and do not support interactive management", command), - "If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.", - ) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "%q is not supported: credentials are provided externally and do not support interactive management", command). + WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.") } diff --git a/internal/cmdutil/factory_test.go b/internal/cmdutil/factory_test.go index dbf343f7..97eed809 100644 --- a/internal/cmdutil/factory_test.go +++ b/internal/cmdutil/factory_test.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -179,14 +180,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) { f.IdentityAutoDetected = true err := f.CheckIdentity(core.AsUser, []string{"bot"}) - if err == nil { - t.Fatal("expected error") + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if !strings.Contains(err.Error(), "resolved identity") { - t.Errorf("expected 'resolved identity' in error, got: %v", err) + if !strings.Contains(ve.Message, "resolved identity") { + t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message) } - if !strings.Contains(err.Error(), "hint: use --as bot") { - t.Errorf("expected hint in error, got: %v", err) + if !strings.Contains(ve.Hint, "use --as bot") { + t.Errorf("expected hint to suggest --as bot, got: %v", ve.Hint) } } @@ -422,20 +424,17 @@ func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) { t.Fatal("expected error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError", err) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want %d", got, output.ExitValidation) } - if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" { - t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider") - } - if exitErr.Detail.Message == "" { + if ve.Message == "" { t.Error("expected non-empty message") } - if exitErr.Detail.Hint == "" { + if ve.Hint == "" { t.Error("expected non-empty hint") } } diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index 45ec3ef0..1f8a4f78 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -12,13 +12,44 @@ import ( "net/http" "sync" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/keychain" extcred "github.com/larksuite/cli/extension/credential" ) +// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the +// canonical typed error. The TAT mint endpoint reports invalid credentials +// with two distinct codes: +// +// - 10003: bad app_id format or non-existent app_id ("invalid param") +// - 10014: invalid app_secret ("app secret invalid") +// +// Both surface as CategoryConfig/InvalidClient from the user's perspective — +// the configured credentials cannot mint a tenant access token. 10014 is +// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543). +// 10003 is NOT globally mapped because in other Lark endpoints it carries +// unrelated semantics (e.g. task API uses 10003 for permission denied), so +// the override stays local to this TAT call site instead of leaking into the +// shared codemeta table. +func classifyTATResponseCode(code int, msg, brand, appID string) error { + if code == 10003 { + return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg). + WithCode(code). + WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient)) + } + return errclass.BuildAPIError(map[string]any{ + "code": code, + "msg": msg, + }, errclass.ClassifyContext{ + Brand: brand, + AppID: appID, + }) +} + // DefaultAccountProvider resolves account from config.json via keychain. type DefaultAccountProvider struct { keychain func() keychain.KeychainAccess @@ -170,7 +201,7 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, return nil, fmt.Errorf("failed to parse TAT response: %w", err) } if result.Code != 0 { - return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg) + return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID) } return &TokenResult{Token: result.TenantAccessToken}, nil } diff --git a/internal/credential/default_provider_test.go b/internal/credential/default_provider_test.go index 5aa25849..46e21f06 100644 --- a/internal/credential/default_provider_test.go +++ b/internal/credential/default_provider_test.go @@ -4,7 +4,10 @@ package credential import ( + "errors" "testing" + + "github.com/larksuite/cli/errs" ) func TestDefaultTokenProvider_Dispatches(t *testing.T) { @@ -15,3 +18,68 @@ func TestDefaultTokenProvider_Dispatches(t *testing.T) { func TestDefaultAccountProvider_Implements(t *testing.T) { var _ DefaultAccountResolver = &DefaultAccountProvider{} } + +// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT +// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient. +// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint, +// which from the user's perspective is the same actionable failure as 10014 +// ("app secret invalid") — both mean the configured credentials cannot mint a +// tenant access token. The global codemeta intentionally does not map 10003 +// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task +// API uses it for permission denied), so the override is local to this site. +func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) { + err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x") + if err == nil { + t.Fatal("expected non-nil error for code=10003") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Category != errs.CategoryConfig { + t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != 10003 { + t.Errorf("Code = %d, want 10003", cfgErr.Code) + } + if cfgErr.Hint == "" { + t.Error("Hint must be non-empty so the user gets a recovery action") + } +} + +// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still +// goes through the global BuildAPIError path (codemeta entry) so the override +// for 10003 does not regress the existing mapping. +func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) { + err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x") + if err == nil { + t.Fatal("expected non-nil error for code=10014") + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != 10014 { + t.Errorf("Code = %d, want 10014", cfgErr.Code) + } +} + +// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside +// the credential set fall through to the generic BuildAPIError fallback +// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional. +func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) { + err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x") + if err == nil { + t.Fatal("expected non-nil error for unmapped code") + } + var cfgErr *errs.ConfigError + if errors.As(err, &cfgErr) { + t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err) + } +} diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index a40e9200..177d6952 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -20,6 +20,7 @@ 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 + LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm } // BuildAPIError consumes a parsed Lark API response and returns a typed error. @@ -35,7 +36,7 @@ type ClassifyContext struct { // Network → *errs.NetworkError // Internal → *errs.InternalError // Confirmation → *errs.ConfirmationRequiredError -// default (CategoryAPI) → *errs.APIError (Detail preserves raw response) +// default (CategoryAPI) → *errs.APIError (catch-all for classified Lark business errors) // // Unknown Lark codes (LookupCodeMeta returns false) fall back to // CategoryAPI + SubtypeUnknown. @@ -80,6 +81,17 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error { LogID: logID, Retryable: meta.Retryable, } + // Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted + // universally before the category switch so every classified typed + // error surfaces it when present. The remaining contents of resp["error"] + // (permission_violations.subject, data.challenge_url, data.hint) are + // either lifted into category-specific typed extension fields below or + // intentionally dropped as redundant with the typed envelope. + if errBlock, ok := resp["error"].(map[string]any); ok { + if ts, _ := errBlock["troubleshooter"].(string); ts != "" { + base.Troubleshooter = ts + } + } switch meta.Category { case errs.CategoryAuthorization: @@ -87,7 +99,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error { case errs.CategoryAuthentication: return &errs.AuthenticationError{Problem: base} case errs.CategoryConfig: - return &errs.ConfigError{Problem: base} + return buildConfigError(base) case errs.CategoryPolicy: return buildSecurityPolicyError(base, resp) case errs.CategoryValidation: @@ -97,9 +109,39 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error { case errs.CategoryInternal: return &errs.InternalError{Problem: base} case errs.CategoryConfirmation: - return &errs.ConfirmationRequiredError{Problem: base} + // Risk + Action are non-omitempty wire fields. Derive from + // CodeMeta when available; otherwise emit RiskUnknown + + // ctx.LarkCmd placeholder so the envelope is never wire-invalid. + risk := meta.Risk + if risk == "" { + risk = errs.RiskUnknown + } + action := meta.Action + if action == "" { + action = cc.LarkCmd + } + if action == "" { + action = "unknown" + } + return &errs.ConfirmationRequiredError{ + Problem: base, + Risk: risk, + Action: action, + } + case errs.CategoryAPI: + return &errs.APIError{Problem: base} default: - return &errs.APIError{Problem: base, Detail: resp} + // Fail closed: an unrecognized Category routes to InternalError + // instead of emitting an empty Problem on the wire. + return &errs.InternalError{ + Problem: errs.Problem{ + Category: errs.CategoryInternal, + Subtype: errs.SubtypeSDKError, + Code: base.Code, + Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code), + LogID: base.LogID, + }, + } } } @@ -149,7 +191,7 @@ func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.Securit // 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. +// the two collapse once the auth transport adopts BuildAPIError directly. func isHTTPSURL(rawURL string) bool { if rawURL == "" { return false @@ -167,47 +209,142 @@ func stringFromAny(v any) string { return s } +// buildConfigError enriches a typed ConfigError with the canonical +// per-subtype recovery hint before returning it, so the wire envelope +// emitted via BuildAPIError always carries a hint for known config subtypes. +func buildConfigError(p errs.Problem) *errs.ConfigError { + p.Hint = ConfigHint(p.Subtype) + return &errs.ConfigError{Problem: p} +} + +// ConfigHint returns the canonical per-subtype recovery hint for a typed +// ConfigError emitted via BuildAPIError. +func ConfigHint(subtype errs.Subtype) string { + switch subtype { + case errs.SubtypeInvalidClient: + return "run `lark-cli config init` to set valid app_id and app_secret" + case errs.SubtypeNotConfigured: + return "run `lark-cli config init` to set up app_id and app_secret" + case errs.SubtypeInvalidConfig: + return "check the config file for syntax errors; rerun `lark-cli config init` to reset" + } + return "" +} + 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{ + consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing) + p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message) + p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL) + permErr := &errs.PermissionError{ Problem: p, MissingScopes: missing, Identity: identity, - ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing), } + // ConsoleURL is the developer-console deep-link an app developer follows to + // apply for a missing scope. That action only resolves SubtypeAppScopeNotApplied, + // which is bot-perspective. The other authorization subtypes route to a + // different actor: SubtypeMissingScope / SubtypeTokenScopeInsufficient / + // SubtypeUserUnauthorized recover via `lark-cli auth login`; SubtypeAppUnavailable + // / SubtypeAppDisabled require tenant admin. Carrying ConsoleURL on those + // envelopes is dead weight and risks pointing an end user at a console they + // cannot modify; the URL is still computed so the hint composer can use it + // where appropriate. + if p.Subtype == errs.SubtypeAppScopeNotApplied { + permErr.ConsoleURL = consoleURL + } + return permErr } -// 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. +// CanonicalPermissionMessage returns the CLI-side canonical wording for a +// typed PermissionError, preserving the Lark official-API phrasing +// ("access denied" / "unauthorized" / "token has no permission") and +// enhancing it with CLI context (app ID, missing scope list). Subtypes +// outside the known set fall through to fallback so the upstream message +// is preserved. +func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []string, fallback string) string { + switch subtype { + case errs.SubtypeAppScopeNotApplied: + if len(missing) > 0 { + scopes := strings.Join(missing, ", ") + if appID != "" { + return fmt.Sprintf("access denied: app %s has not applied for the required scope(s): %s", appID, scopes) + } + return fmt.Sprintf("access denied: app has not applied for the required scope(s): %s", scopes) + } + if appID != "" { + return fmt.Sprintf("access denied: app %s has not applied for the required scope(s)", appID) + } + return "access denied: app has not applied for the required scope(s)" + case errs.SubtypeMissingScope: + if len(missing) > 0 { + return fmt.Sprintf("unauthorized: user authorization does not cover the required scope(s): %s", strings.Join(missing, ", ")) + } + return "unauthorized: user authorization does not cover the required scope" + case errs.SubtypeTokenScopeInsufficient: + return "token has no permission for this operation; required scope is missing" + case errs.SubtypeUserUnauthorized: + return "access denied for this operation; possible causes: missing scope, missing user authorization, or restricted by tenant policy" + case errs.SubtypeAppUnavailable: + if appID != "" { + return fmt.Sprintf("unauthorized app: app %s is not properly installed in this tenant", appID) + } + return "unauthorized app: app is not properly installed in this tenant" + case errs.SubtypeAppDisabled: + if appID != "" { + return fmt.Sprintf("app %s is not in use in this tenant (currently disabled)", appID) + } + return "app is not in use in this tenant (currently disabled)" + case errs.SubtypePermissionDenied: + return "user lacks permission for the requested resource" + } + return fallback +} + +// PermissionHint returns the canonical per-subtype recovery hint for a typed +// PermissionError. The hint distinguishes authorization subtypes routing +// to different recovery paths: developer console for app_scope_not_applied, +// user re-login for missing_scope / token_scope_insufficient / user_unauthorized, +// and tenant admin for app_unavailable / app_disabled. The subtype +// argument is the primary discriminator; identity is retained for the +// generic permission_denied fallback so callers that do not yet route on +// subtype still get a sensible hint. // // 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" +func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string { + switch subtype { + case errs.SubtypeAppScopeNotApplied: + if consoleURL != "" { + return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL) } - return "ensure the calling identity has been granted the required scopes" + return "the app developer must apply for the required scope(s) at the developer console" + case errs.SubtypeMissingScope: + if len(missing) > 0 { + return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " ")) + } + return "run `lark-cli auth login` to re-authorize the user with the updated scope set" + case errs.SubtypeTokenScopeInsufficient: + return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued" + case errs.SubtypeUserUnauthorized: + return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy" + case errs.SubtypeAppUnavailable: + return "ask the tenant admin to check the app's install status in the Lark admin console" + case errs.SubtypeAppDisabled: + return "ask the tenant admin to re-enable the app in the Lark admin console" + case errs.SubtypePermissionDenied: + who := "this user" + if identity == "bot" { + who = "this bot" + } + return fmt.Sprintf("check the resource owner has granted access to %s", who) } - 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) + return "check the calling identity has the required scope" } // extractMissingScopes walks resp["error"]["permission_violations"][].subject. diff --git a/internal/errclass/classify_internal_test.go b/internal/errclass/classify_internal_test.go new file mode 100644 index 00000000..83714d03 --- /dev/null +++ b/internal/errclass/classify_internal_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestBuildAPIError_CategoryConfirmationFillsRiskAction pins fail-closed +// behaviour: a code mapped to CategoryConfirmation MUST yield a +// ConfirmationRequiredError whose Risk + Action are non-empty even when the +// CodeMeta itself carries no Risk/Action hints. Risk falls back to +// RiskUnknown; Action falls back to ctx.LarkCmd. +func TestBuildAPIError_CategoryConfirmationFillsRiskAction(t *testing.T) { + const stubCode = 99999991 + codeMeta[stubCode] = CodeMeta{ + Category: errs.CategoryConfirmation, + Subtype: errs.SubtypeConfirmationRequired, + } + t.Cleanup(func() { delete(codeMeta, stubCode) }) + + resp := map[string]any{"code": stubCode, "msg": "confirmation required"} + ctx := ClassifyContext{ + Brand: "feishu", + AppID: "cli_test", + Identity: "user", + LarkCmd: "drive +delete", + } + err := BuildAPIError(resp, ctx) + var confirmErr *errs.ConfirmationRequiredError + if !errors.As(err, &confirmErr) { + t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err) + } + if confirmErr.Risk == "" { + t.Error("Risk empty; arm must fail-closed with RiskUnknown") + } + if confirmErr.Risk != errs.RiskUnknown { + t.Errorf("Risk = %q, want %q (CodeMeta carried no Risk hint)", + confirmErr.Risk, errs.RiskUnknown) + } + if confirmErr.Action == "" { + t.Error("Action empty; arm must fail-closed with command name from ClassifyContext") + } + if confirmErr.Action != "drive +delete" { + t.Errorf("Action = %q, want %q (ctx.LarkCmd fallback)", + confirmErr.Action, "drive +delete") + } +} + +// TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints pins that when +// CodeMeta carries explicit Risk + Action, the dispatcher uses them rather +// than falling back to RiskUnknown / ctx.LarkCmd. +func TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints(t *testing.T) { + const stubCode = 99999992 + codeMeta[stubCode] = CodeMeta{ + Category: errs.CategoryConfirmation, + Subtype: errs.SubtypeConfirmationRequired, + Risk: errs.RiskHighRiskWrite, + Action: "wiki:delete-space", + } + t.Cleanup(func() { delete(codeMeta, stubCode) }) + + resp := map[string]any{"code": stubCode, "msg": "confirmation required"} + ctx := ClassifyContext{LarkCmd: "drive +delete"} + err := BuildAPIError(resp, ctx) + var confirmErr *errs.ConfirmationRequiredError + if !errors.As(err, &confirmErr) { + t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err) + } + if confirmErr.Risk != errs.RiskHighRiskWrite { + t.Errorf("Risk = %q, want %q (CodeMeta hint should win)", + confirmErr.Risk, errs.RiskHighRiskWrite) + } + if confirmErr.Action != "wiki:delete-space" { + t.Errorf("Action = %q, want %q (CodeMeta hint should win)", + confirmErr.Action, "wiki:delete-space") + } +} + +// TestBuildAPIError_UnknownCategoryRoutesToInternalError pins fail-closed +// behaviour: an unrecognized Category routes to InternalError instead of +// emitting an empty Problem on the wire. +func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) { + const stubCode = 99999993 + codeMeta[stubCode] = CodeMeta{ + Category: errs.Category("totally_unknown_category"), + Subtype: errs.SubtypeUnknown, + } + t.Cleanup(func() { delete(codeMeta, stubCode) }) + + resp := map[string]any{"code": stubCode, "msg": "weird"} + err := BuildAPIError(resp, ClassifyContext{}) + var ie *errs.InternalError + if !errors.As(err, &ie) { + t.Fatalf("expected *InternalError, got %T: %v", err, err) + } + if ie.Category != errs.CategoryInternal { + t.Errorf("Category = %q, want %q", ie.Category, errs.CategoryInternal) + } + if ie.Subtype != errs.SubtypeSDKError { + t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeSDKError) + } + if ie.Code != stubCode { + t.Errorf("Code = %d, want %d (raw Lark code should propagate)", ie.Code, stubCode) + } +} + +// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a +// CategoryConfig response (Lark code 10014 — "app secret invalid") flows +// through BuildAPIError, the resulting *ConfigError MUST carry the canonical +// recovery hint pointing the user at `lark-cli config init`. +func TestBuildAPIError_ConfigInvalidClient_HasHint(t *testing.T) { + const code = 10014 + resp := map[string]any{"code": code, "msg": "app secret invalid"} + ctx := ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "bot"} + + err := BuildAPIError(resp, ctx) + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *ConfigError, got %T: %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Hint == "" { + t.Errorf("Hint is empty; canonical hint required for invalid_client") + } + if !strings.Contains(cfgErr.Hint, "lark-cli config init") { + t.Errorf("Hint should reference `lark-cli config init`; got %q", cfgErr.Hint) + } +} diff --git a/internal/errclass/classify_test.go b/internal/errclass/classify_test.go index 11a01ae8..08cfc0c3 100644 --- a/internal/errclass/classify_test.go +++ b/internal/errclass/classify_test.go @@ -29,6 +29,22 @@ func missingScopeResp(scope string) map[string]any { } } +// appScopeNotAppliedResp builds the Lark response shape for code 99991672 +// ("the app has not applied for the required scope(s)"). Used by tests that +// exercise the bot-perspective ConsoleURL attachment path, which the +// dispatcher restricts to SubtypeAppScopeNotApplied only. +func appScopeNotAppliedResp(scope string) map[string]any { + return map[string]any{ + "code": 99991672, + "msg": "app scope not applied", + "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) @@ -95,8 +111,8 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) { {"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"}, + {"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"}, + {"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"}, {"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"}, @@ -129,29 +145,92 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) { } } -// 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) { +// TestBuildAPIError_TaskInvalidParamsRoutesToAPIError pins that code 1470400 +// (Lark API-side parameter rejection) routes to *errs.APIError + CategoryAPI +// + SubtypeInvalidParameters. CategoryValidation is reserved for CLI-side +// (caller-side) flag/arg validation, never reachable from API responses; +// classify_test pins the API-side classification here so a regression that +// re-introduces the misclassification fails fast. +func TestBuildAPIError_TaskInvalidParamsRoutesToAPIError(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) + var ae *errs.APIError + if !errors.As(err, &ae) { + t.Fatalf("expected *errs.APIError, got %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) + if p.Category != errs.CategoryAPI { + t.Errorf("Category = %q, want %q", p.Category, errs.CategoryAPI) + } + if p.Subtype != errs.SubtypeInvalidParameters { + t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidParameters) + } +} + +// TestBuildAPIError_TroubleshooterLiftedOnAPIArm pins that BuildAPIError lifts +// resp.error.troubleshooter into Problem.Troubleshooter when the response +// routes to the catch-all CategoryAPI arm. troubleshooter is the only +// resp.error field with genuinely non-redundant content vs typed envelope +// fields; the rest (permission_violations.subject, log_id, challenge_url) is +// already lifted by category-specific paths. +func TestBuildAPIError_TroubleshooterLiftedOnAPIArm(t *testing.T) { + resp := map[string]any{ + "code": 1470400, + "msg": "bad params", + "error": map[string]any{ + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/x", + }, + } + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatal("ProblemOf returned !ok") + } + if p.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/x" { + t.Errorf("Troubleshooter = %q, want passthrough", p.Troubleshooter) + } +} + +// TestBuildAPIError_TroubleshooterLiftedOnPermissionArm pins that +// troubleshooter surfaces on classified non-API arms too — BuildAPIError lifts +// it before the category switch so PermissionError / ConfigError / etc. inherit +// the same wire vocab. +func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) { + resp := map[string]any{ + "code": 99991679, + "msg": "missing scope", + "error": map[string]any{ + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/scope", + "permission_violations": []any{map[string]any{"subject": "docx:document"}}, + }, + } + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"}) + var pe *errs.PermissionError + if !errors.As(err, &pe) { + t.Fatalf("expected *errs.PermissionError, got %T", err) + } + if pe.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/scope" { + t.Errorf("Troubleshooter = %q, want lifted on PermissionError", pe.Troubleshooter) + } +} + +// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty +// when the upstream response omits it — wire envelope must omit the field. +func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) { + resp := map[string]any{"code": 1470400, "msg": "bad params"} + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatal("ProblemOf returned !ok") + } + if p.Troubleshooter != "" { + t.Errorf("Troubleshooter = %q, want empty when resp omits it", p.Troubleshooter) } } @@ -182,8 +261,6 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) { `"code": 99991679`, `"missing_scopes":`, `"docx:document"`, - `"console_url":`, - `open.feishu.cn/app/cli_a123/auth`, `"identity": "user"`, `"log_id": "lg-1"`, } { @@ -196,6 +273,12 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) { `"component"`, `"doc_url"`, `"retryable":`, // Retryable defaults false, omitempty → key absent + // console_url is gated to SubtypeAppScopeNotApplied (bot-perspective + // dev-action recovery). For user-perspective missing_scope the only + // actionable recovery is `lark-cli auth login --scope ...` (already + // in Hint), so the URL is dropped from the wire to avoid pointing an + // end user at a console they cannot modify. + `"console_url":`, } { if strings.Contains(out, mustNot) { t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out) @@ -228,8 +311,8 @@ func TestRetryableEnvelope_TrueOnly(t *testing.T) { } func TestConsoleURL_FeishuBrand(t *testing.T) { - resp := missingScopeResp("docx:document") - err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}) + resp := appScopeNotAppliedResp("docx:document") + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}) pe, ok := err.(*errs.PermissionError) if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) @@ -240,8 +323,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) { } func TestConsoleURL_LarkBrand(t *testing.T) { - resp := missingScopeResp("docx:document") - err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"}) + resp := appScopeNotAppliedResp("docx:document") + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"}) pe, ok := err.(*errs.PermissionError) if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) @@ -252,14 +335,36 @@ func TestConsoleURL_LarkBrand(t *testing.T) { } func TestConsoleURL_EmptyAppID(t *testing.T) { - resp := missingScopeResp("docx:document") - err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"}) + resp := appScopeNotAppliedResp("docx:document") + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"}) pe := err.(*errs.PermissionError) if pe.ConsoleURL != "" { t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL) } } +// TestConsoleURL_AttachedOnlyForAppScopeNotApplied pins the gating rule: +// the developer-console deep-link only rides on the wire for +// SubtypeAppScopeNotApplied (where the recovery is "developer applies the +// scope"). User-perspective subtypes such as SubtypeMissingScope recover via +// `lark-cli auth login --scope ...`, so the URL is dead weight on those +// envelopes and is intentionally omitted to avoid pointing an end user at a +// console they cannot modify. +func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) { + cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"} + + bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError) + if bot.ConsoleURL == "" { + t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty") + } + + user := errclass.BuildAPIError(missingScopeResp("docx:document"), + errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError) + if user.ConsoleURL != "" { + t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.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). @@ -335,9 +440,10 @@ func TestPermissionError_DefaultIdentity(t *testing.T) { 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"}) + // ConsoleURL falls back to the no-scope form. Exercises the bot-perspective + // SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides. + resp := map[string]any{"code": 99991672, "msg": "x"} + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}) pe := err.(*errs.PermissionError) if pe.MissingScopes != nil { t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes) @@ -367,20 +473,24 @@ func TestExtractMissingScopes_Dedup(t *testing.T) { } } -// 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). +// TestServiceShortcutEnvelopeConverge guards that the wire envelope produced +// by the dispatcher (BuildAPIError — the normal service / shortcut path) +// converges with the envelope produced by the direct-construction path used +// in cmd/service/service.go's checkServiceScopes pre-flight check. // -// 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. +// Both paths now share the same canonical helpers in internal/errclass for +// Message (CanonicalPermissionMessage), Hint (PermissionHint), and +// ConsoleURL (ConsoleURL); MissingScopes and Identity are filled identically. +// A future drift on either side (e.g. a new extension field on +// PermissionError that only BuildAPIError populates, or service.go inlining +// its own message string again) fails this test loudly. +// +// One upstream-derived field is a documented exception: `code` (the Lark +// API numeric code). The pre-flight check runs against a locally cached +// scope list and has no upstream response to extract it from. The +// comparison below strips that key from both envelopes so the assertion +// isolates the contract fields that MUST converge: Subtype, Category, +// Message, Hint, Identity, MissingScopes, ConsoleURL. func TestServiceShortcutEnvelopeConverge(t *testing.T) { const ( brand = "feishu" @@ -392,27 +502,21 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) { // 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 { + if _, ok := dispatcherErr.(*errs.PermissionError); !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), - } + // Path B: direct construction — exercises the same helpers that + // cmd/service/service.go's newPreflightMissingScopeError uses. Keep this + // in lock-step with that helper; if either drifts the byte-comparison + // fails. ConsoleURL is intentionally NOT set on either path for + // SubtypeMissingScope — see the gating rationale in buildPermissionError. + consoleURL := errclass.ConsoleURL(brand, appID, missing) + directErr := errs.NewPermissionError(errs.SubtypeMissingScope, + "%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")). + WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)). + WithMissingScopes(missing...). + WithIdentity(identity) var bufA, bufB bytes.Buffer if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok { @@ -422,11 +526,34 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) { 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()) + // Strip `code` from both envelopes — see test doc above. + stripA := stripUpstreamFields(t, bufA.Bytes()) + stripB := stripUpstreamFields(t, bufB.Bytes()) + if stripA != stripB { + t.Errorf("dispatcher vs direct-construction envelopes diverge (upstream fields stripped):\nDispatcher: %s\nDirect: %s", stripA, stripB) } } +// stripUpstreamFields parses an envelope JSON and re-marshals it with the +// upstream-derived "code" key removed from the inner "error" block. Used by +// the convergence test to isolate contract fields shared between the +// dispatcher and pre-flight paths. +func stripUpstreamFields(t *testing.T, raw []byte) string { + t.Helper() + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("envelope not valid JSON: %v\nraw: %s", err, raw) + } + if errBlock, ok := obj["error"].(map[string]any); ok { + delete(errBlock, "code") + } + out, err := json.Marshal(obj) + if err != nil { + t.Fatalf("re-marshal failed: %v", err) + } + return string(out) +} + func TestDirectPermissionPath_TypedExitCode(t *testing.T) { // Mirrors what the cmd/service direct-construction path produces. pe := &errs.PermissionError{ @@ -492,44 +619,48 @@ func TestBuildAPIError_LogIDTopLevel(t *testing.T) { } } -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_MissingScopeRoutesToAuthLogin(t *testing.T) { + // missing_scope means the user authorized the app but did not grant + // this scope — recoverable by re-running `auth login`. Both user and + // bot identities route the same way because the recovery action is + // user-initiated either way. + for _, identity := range []string{"user", "bot", ""} { + got := errclass.PermissionHint([]string{"docx:document", "im:message"}, identity, errs.SubtypeMissingScope, "") + if !strings.Contains(got, "lark-cli auth login") { + t.Errorf("identity=%q: hint should suggest `lark-cli auth login`; got %q", identity, got) + } + if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") { + t.Errorf("identity=%q: hint should include missing scopes; got %q", identity, 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) + // missing_scope with empty list — still suggests auth login even + // without the explicit --scope argument. + if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope, ""); !strings.Contains(got, "lark-cli auth login") { + t.Errorf("missing_scope no-scope hint should still suggest auth login; 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) + // app_scope_not_applied without console URL — still points at the + // developer console (URL is optional context, not a routing axis). + if got := errclass.PermissionHint(nil, "user", errs.SubtypeAppScopeNotApplied, ""); !strings.Contains(got, "developer console") { + t.Errorf("app_scope_not_applied no-URL hint should still point at developer console; got %q", got) } } func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) { - // 99991672 / app_scope_not_enabled means the scope has not been granted + // 99991672 / app_scope_not_applied 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. + consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact" 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) + got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL) + if !strings.Contains(got, "developer console") { + t.Errorf("identity=%q: hint should point to developer console; got %q", identity, got) + } + if !strings.Contains(got, consoleURL) { + t.Errorf("identity=%q: hint should embed the console URL; got %q", identity, got) } if strings.Contains(got, "auth login") { t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got) @@ -537,6 +668,123 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) { } } +// TestBuildPermissionError_CanonicalMessage pins the per-subtype canonical +// wording so the wire envelope's Message preserves Lark's official phrasing +// ("access denied" / "unauthorized" / "token has no permission") and enhances +// it with CLI context (app ID, scope list). Regressions here are user-visible. +func TestBuildPermissionError_CanonicalMessage(t *testing.T) { + const appID = "cli_xyz" + cases := []struct { + name string + code int + wantSubtype errs.Subtype + // substrings the canonical message MUST contain + wantSubstrs []string + }{ + { + name: "99991672 app_scope_not_applied", + code: 99991672, + wantSubtype: errs.SubtypeAppScopeNotApplied, + wantSubstrs: []string{"access denied", "app " + appID, "contact:contact"}, + }, + { + name: "99991679 missing_scope", + code: 99991679, + wantSubtype: errs.SubtypeMissingScope, + wantSubstrs: []string{"unauthorized", "user authorization", "contact:contact"}, + }, + { + name: "99991676 token_scope_insufficient", + code: 99991676, + wantSubtype: errs.SubtypeTokenScopeInsufficient, + wantSubstrs: []string{"token has no permission"}, + }, + { + name: "230027 user_unauthorized", + code: 230027, + wantSubtype: errs.SubtypeUserUnauthorized, + wantSubstrs: []string{"access denied for this operation"}, + }, + { + name: "99991673 app_unavailable", + code: 99991673, + wantSubtype: errs.SubtypeAppUnavailable, + wantSubstrs: []string{"unauthorized app", "app " + appID, "not properly installed"}, + }, + { + name: "99991662 app_disabled", + code: 99991662, + wantSubtype: errs.SubtypeAppDisabled, + wantSubstrs: []string{"app " + appID, "not in use", "currently disabled"}, + }, + { + name: "1470403 permission_denied", + code: 1470403, + wantSubtype: errs.SubtypePermissionDenied, + wantSubstrs: []string{"user lacks permission"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := map[string]any{ + "code": tc.code, + "msg": "upstream raw text — must be replaced", + "error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}}, + } + err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"}) + pe, ok := err.(*errs.PermissionError) + if !ok { + t.Fatalf("expected *PermissionError, got %T", err) + } + if pe.Subtype != tc.wantSubtype { + t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype) + } + for _, sub := range tc.wantSubstrs { + if !strings.Contains(pe.Message, sub) { + t.Errorf("Message %q missing substring %q", pe.Message, sub) + } + } + if pe.Message == "upstream raw text — must be replaced" { + t.Errorf("Message must be rewritten to canonical text, got upstream verbatim: %q", pe.Message) + } + }) + } +} + +// TestCanonicalPermissionMessage_FallbackOnUnknownSubtype pins that an unknown +// subtype (not in the per-subtype switch) preserves the upstream fallback +// instead of producing an empty Message. +func TestCanonicalPermissionMessage_FallbackOnUnknownSubtype(t *testing.T) { + got := errclass.CanonicalPermissionMessage(errs.SubtypeUnknown, "cli_x", nil, "upstream verbatim") + if got != "upstream verbatim" { + t.Errorf("unknown subtype should preserve fallback; got %q", got) + } +} + +// TestCanonicalPermissionMessage_EmptyAppIDStillReadable pins the no-app-id +// fallback wording so an early-init bootstrap path that produces a +// PermissionError without ClassifyContext.AppID still emits useful text. +func TestCanonicalPermissionMessage_EmptyAppIDStillReadable(t *testing.T) { + cases := []struct { + sub errs.Subtype + substr string + appIDIn string + }{ + {errs.SubtypeAppScopeNotApplied, "app has not applied", ""}, + {errs.SubtypeAppUnavailable, "app is not properly installed", ""}, + {errs.SubtypeAppDisabled, "app is not in use", ""}, + } + for _, tc := range cases { + got := errclass.CanonicalPermissionMessage(tc.sub, tc.appIDIn, nil, "") + if !strings.Contains(got, tc.substr) { + t.Errorf("subtype=%s no-app-id message missing %q: got %q", tc.sub, tc.substr, got) + } + if strings.Contains(got, " app ") || strings.Contains(got, "app : ") { + t.Errorf("subtype=%s no-app-id message has double space placeholder: %q", tc.sub, 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 @@ -554,8 +802,8 @@ func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testin 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, "developer console") { + t.Errorf("Hint should route to developer 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) diff --git a/internal/errclass/codemeta.go b/internal/errclass/codemeta.go index 572fc4dc..7c41c875 100644 --- a/internal/errclass/codemeta.go +++ b/internal/errclass/codemeta.go @@ -12,10 +12,16 @@ import ( // 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). +// +// Risk + Action are populated only for codes that route to CategoryConfirmation; +// the dispatcher falls back to RiskUnknown + ctx.LarkCmd when either is empty +// so the envelope is never wire-invalid. type CodeMeta struct { Category errs.Category Subtype errs.Subtype Retryable bool + Risk string // CategoryConfirmation arm only; empty otherwise + Action string // CategoryConfirmation arm only; empty otherwise } // codeMeta is the central registry. Top-level entries (auth/authorization/api/ @@ -27,42 +33,43 @@ type CodeMeta struct { // 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 + 99991661: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}, // Authorization header missing + 99991671: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // token format error (must start with t- / u-) + 99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish) + 99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid + 99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired + 20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format + 20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired + 20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked + 20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used + 20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: 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 + 99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied}, + 99991676: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeTokenScopeInsufficient}, + 99991679: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}, // user authorized app but did not grant this scope + 230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app + 99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable + 99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled 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}, + 99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true}, + 1061045: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, + 131009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // wiki write-path lock contention; retryable with backoff + 1064510: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossTenant}, + 1064511: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossBrand}, + 1310246: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, + 1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable + 1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, + 231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch}, // CategoryConfig - 99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect + 99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API) + 10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543) // CategoryPolicy - 21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false}, - 21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false}, + 21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired}, + 21001: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied}, } // LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes — diff --git a/internal/errclass/codemeta_task.go b/internal/errclass/codemeta_task.go index 4d5e3c72..9449eabb 100644 --- a/internal/errclass/codemeta_task.go +++ b/internal/errclass/codemeta_task.go @@ -5,20 +5,21 @@ 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. +// taskCodeMeta holds task-service Lark code → CodeMeta mappings. +// All Subtypes are framework-shared (errs.SubtypeXxx) — task does not declare +// service-specific Subtypes because none of these codes carry semantics beyond +// the cross-service taxonomy (NotFound / QuotaExceeded / etc.). +// 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}, + 1470400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid_params + 1470403: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // permission_denied (resource-level) + 1470404: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // not_found + 1470422: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // conflict (retryable) + 1470500: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // server_error (retryable) + 1470610: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // assignee_limit + 1470611: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // follower_limit + 1470612: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tasklist_member_limit + 1470613: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // reminder_exists } func init() { mergeCodeMeta(taskCodeMeta, "task") } diff --git a/internal/errclass/codemeta_test.go b/internal/errclass/codemeta_test.go index ff965ae5..30015907 100644 --- a/internal/errclass/codemeta_test.go +++ b/internal/errclass/codemeta_test.go @@ -4,12 +4,45 @@ package errclass import ( + "fmt" "strings" "testing" "github.com/larksuite/cli/errs" ) +func TestLookupCodeMeta_CredentialCodes(t *testing.T) { + cases := []struct { + code int + wantCat errs.Category + wantSubtype errs.Subtype + wantRetry bool + }{ + {99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, + {99991671, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, + {99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, + {99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, + {99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, + {20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, + {20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, + {20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, + {20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, + {20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { + meta, ok := LookupCodeMeta(tc.code) + if !ok { + t.Fatalf("code %d not registered in codeMeta", tc.code) + } + if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry { + t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v", + tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry) + } + }) + } +} + func TestLookupCodeMeta_MissingScope(t *testing.T) { got, ok := LookupCodeMeta(99991679) if !ok { @@ -29,8 +62,8 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) { 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.Subtype != errs.SubtypePermissionDenied { + t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypePermissionDenied) } if got.Retryable { t.Errorf("Retryable = true, want false") @@ -70,6 +103,27 @@ func TestLookupCodeMeta_Unknown(t *testing.T) { } } +// TestLookupCodeMeta_ConfigCode_99991543 pins the Lark "app_id or app_secret +// is incorrect" code to CategoryConfig / SubtypeInvalidClient. The CLI cannot +// retry around a wrong app credential — the operator has to edit the local +// config — so this MUST stay non-retryable and live in the config category +// (not the API category it was originally classed under). +func TestLookupCodeMeta_ConfigCode_99991543(t *testing.T) { + meta, ok := LookupCodeMeta(99991543) + if !ok { + t.Fatal("99991543 not registered in codeMeta") + } + if meta.Category != errs.CategoryConfig { + t.Errorf("category = %v, want %v", meta.Category, errs.CategoryConfig) + } + if meta.Subtype != errs.SubtypeInvalidClient { + t.Errorf("subtype = %v, want %v", meta.Subtype, errs.SubtypeInvalidClient) + } + if meta.Retryable { + t.Errorf("Retryable = true, want false (wrong app credential is operator-fix)") + } +} + func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) { got, ok := LookupCodeMeta(21000) if !ok { @@ -93,7 +147,7 @@ func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) { if !ok { t.Fatalf("panic value is not a string: %T (%v)", r, r) } - for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} { + for _, needle := range []string{"1470403", "permission_denied", "intruder", "test"} { if !strings.Contains(msg, needle) { t.Errorf("panic message %q missing substring %q", msg, needle) } diff --git a/internal/errcompat/promote.go b/internal/errcompat/promote.go index dc4638e2..7f5cd6fa 100644 --- a/internal/errcompat/promote.go +++ b/internal/errcompat/promote.go @@ -1,32 +1,48 @@ // 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 provides boundary helpers that bridge legacy error types +// to the typed errs/ taxonomy. These helpers run at the dispatcher boundary +// (cmd/root.go.handleRootError) before the typed envelope writer, converting +// pre-typed-taxonomy errors (*core.ConfigError, *internalauth.NeedAuthorizationError) +// into typed *errs.* errors while preserving the original error in the Cause +// chain so existing `errors.As` callers continue to match. package errcompat import ( + "strings" + "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. +// PromoteConfigError converts a legacy *core.ConfigError into the matching +// typed errs.*Error based on cfgErr.Type. Called from cmd/root.go.handleRootError +// before the typed envelope writer. The original *core.ConfigError is preserved +// in the Cause chain so external `errors.As(&core.ConfigError{})` callers +// (cmd/auth/list.go, cmd/doctor/doctor.go, etc.) still match. func PromoteConfigError(cfgErr *core.ConfigError) error { if cfgErr == nil { return nil } - return cfgErr + switch cfgErr.Type { + case "auth": + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", cfgErr.Message). + WithHint("%s", cfgErr.Hint). + WithCause(cfgErr) + case "config": + subtype := errs.SubtypeNotConfigured + lower := strings.ToLower(cfgErr.Message) + if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") { + subtype = errs.SubtypeInvalidConfig + } + return errs.NewConfigError(subtype, "%s", cfgErr.Message). + WithHint("%s", cfgErr.Hint). + WithCause(cfgErr) + default: + // dynamic Type (e.g. workspace name like "bind"/"hermes"/"openclaw") → NotConfigured + return errs.NewConfigError(errs.SubtypeNotConfigured, "%s", cfgErr.Message). + WithHint("%s", cfgErr.Hint). + WithCause(cfgErr) + } } - -// _ keeps the errs import live so stage-2 fill-in does not need to re-add it. -var _ = errs.CategoryConfig diff --git a/internal/errcompat/promote_auth.go b/internal/errcompat/promote_auth.go new file mode 100644 index 00000000..b85457c6 --- /dev/null +++ b/internal/errcompat/promote_auth.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errcompat + +import ( + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" +) + +// PromoteAuthError converts a legacy *internalauth.NeedAuthorizationError into +// *errs.AuthenticationError{Subtype: TokenMissing}. The Message field MUST +// contain "need_user_authorization" so the marker invariant guardrail in +// cmd/root_test.go and internal/auth/errors_test.go still holds. +// +// Hint mirrors newTokenMissingError in internal/client/client.go so both +// token-missing surfaces converge on the same recovery vocabulary. cmd's +// applyNeedAuthorizationHint appends per-command scopes onto this Hint with +// a "\n" join, so the action prompt is preserved even when scopes are added. +// +// Called from cmd/root.go.handleRootError when errors.As matches +// *NeedAuthorizationError, before WriteTypedErrorEnvelope. +func PromoteAuthError(err *internalauth.NeedAuthorizationError) error { + if err == nil { + return nil + } + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, + "need_user_authorization (user: %s)", err.UserOpenId). + WithUserOpenID(err.UserOpenId). + WithHint("run: lark-cli auth login to re-authorize"). + WithCause(err) +} diff --git a/internal/errcompat/promote_auth_test.go b/internal/errcompat/promote_auth_test.go new file mode 100644 index 00000000..8e670c64 --- /dev/null +++ b/internal/errcompat/promote_auth_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errcompat + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + internalauth "github.com/larksuite/cli/internal/auth" +) + +func TestPromoteAuthError_PromotesNeedAuthorizationError(t *testing.T) { + needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"} + got := PromoteAuthError(needAuth) + + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } + + // Cause chain must preserve original *NeedAuthorizationError so legacy + // consumers (auth.IsNeedUserAuthorizationError + errors.As pattern in + // internal/auth/errors.go:42) still match. + var preserved *internalauth.NeedAuthorizationError + if !errors.As(got, &preserved) { + t.Error("Unwrap chain lost *NeedAuthorizationError — breaks auth.IsNeedUserAuthorizationError consumer") + } +} + +func TestPromoteAuthError_PreservesNeedUserAuthorizationMarker(t *testing.T) { + needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"} + got := PromoteAuthError(needAuth) + if !strings.Contains(got.Error(), "need_user_authorization") { + t.Errorf("Message must contain need_user_authorization marker, got: %q", got.Error()) + } +} + +func TestPromoteAuthError_PreservesUserOpenID(t *testing.T) { + needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_test_open_id"} + got := PromoteAuthError(needAuth) + + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.UserOpenID != "u_test_open_id" { + t.Errorf("UserOpenID = %q, want preserved", authErr.UserOpenID) + } +} + +// TestPromoteAuthError_CarriesAuthLoginHint pins that the recovery action +// prompt is attached at promotion time — without this Hint, downstream +// consumers see authentication/token_missing but no "run: lark-cli auth login" +// guidance, mirroring the pre-typed UX failure when NeedAuthorizationError +// surfaced as a bare network error. cmd's applyNeedAuthorizationHint relies +// on this Hint being non-empty so scope enrichment appends instead of +// overwrites the recovery prompt. +func TestPromoteAuthError_CarriesAuthLoginHint(t *testing.T) { + got := PromoteAuthError(&internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}) + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if !strings.Contains(authErr.Hint, "lark-cli auth login") { + t.Errorf("Hint must guide user to re-authorize, got: %q", authErr.Hint) + } +} + +func TestPromoteAuthError_Nil_ReturnsNil(t *testing.T) { + if got := PromoteAuthError(nil); got != nil { + t.Errorf("nil input should return nil, got %v", got) + } +} diff --git a/internal/errcompat/promote_test.go b/internal/errcompat/promote_test.go index 43ffea74..cebeb9b2 100644 --- a/internal/errcompat/promote_test.go +++ b/internal/errcompat/promote_test.go @@ -5,33 +5,101 @@ package errcompat_test import ( "errors" + "strings" "testing" + "github.com/larksuite/cli/errs" "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) +func TestPromoteConfigError_TypeAuth_PromotesToAuthenticationError(t *testing.T) { + cfg := &core.ConfigError{ + Type: "auth", + Code: 3, + Message: "not logged in", + Hint: "run: lark-cli auth login", + } + got := errcompat.PromoteConfigError(cfg) + + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) + } + // Cause chain must preserve original *core.ConfigError for errors.As compat. + var cfgPreserved *core.ConfigError + if !errors.As(got, &cfgPreserved) { + t.Error("Unwrap chain lost *core.ConfigError — breaks cmd/auth/list.go consumer") + } +} + +func TestPromoteConfigError_TypeConfig_PromotesToConfigError(t *testing.T) { + cases := []struct { + name string + msg string + wantSubtype errs.Subtype + }{ + {"not_configured", "not configured", errs.SubtypeNotConfigured}, + {"invalid_config_parse", "failed to parse config", errs.SubtypeInvalidConfig}, + {"invalid_config_keyword", "invalid config file", errs.SubtypeInvalidConfig}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &core.ConfigError{Type: "config", Code: 3, Message: tc.msg} + got := errcompat.PromoteConfigError(cfg) + + var ce *errs.ConfigError + if !errors.As(got, &ce) { + t.Fatalf("expected *errs.ConfigError, got %T", got) + } + if ce.Subtype != tc.wantSubtype { + t.Errorf("subtype = %v, want %v", ce.Subtype, tc.wantSubtype) } }) } } -// 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) +func TestPromoteConfigError_TypeDynamic_PromotesToConfigError(t *testing.T) { + for _, wsName := range []string{"openclaw", "hermes", "bind"} { + t.Run(wsName, func(t *testing.T) { + cfg := &core.ConfigError{Type: wsName, Code: 3, Message: "not configured"} + got := errcompat.PromoteConfigError(cfg) + + var ce *errs.ConfigError + if !errors.As(got, &ce) { + t.Fatalf("expected *errs.ConfigError, got %T", got) + } + if ce.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %v, want %v", ce.Subtype, errs.SubtypeNotConfigured) + } + }) + } +} + +func TestPromoteConfigError_Nil_ReturnsNil(t *testing.T) { + if got := errcompat.PromoteConfigError(nil); got != nil { + t.Errorf("nil input should return nil, got %v", got) + } +} + +func TestPromoteConfigError_PreservesMessageHint(t *testing.T) { + cfg := &core.ConfigError{ + Type: "auth", + Message: "session expired (user: u_xxx)", + Hint: "re-authenticate", + } + got := errcompat.PromoteConfigError(cfg) + if !strings.Contains(got.Error(), "session expired") { + t.Errorf("message lost in promotion: %v", got) + } + var authErr *errs.AuthenticationError + if !errors.As(got, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T", got) + } + if authErr.Hint != "re-authenticate" { + t.Errorf("hint = %q, want preserved", authErr.Hint) } } diff --git a/internal/output/errors.go b/internal/output/errors.go index ee9caa95..0ed2c44a 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -17,13 +17,8 @@ import ( // 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. +// Deprecated: legacy error type. Return a typed *errs.XxxError instead +// (see errs/types.go). type ExitError struct { Code int Detail *ErrDetail @@ -47,12 +42,12 @@ func (e *ExitError) Unwrap() error { // 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 +// preserves the upstream message verbatim. 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. +// wants the original Lark response wording rather than the enriched +// message/hint variant. func MarkRaw(err error) error { var exitErr *ExitError if errors.As(err, &exitErr) { @@ -63,13 +58,8 @@ func MarkRaw(err error) error { // 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. +// Deprecated: legacy envelope writer. Typed errors are dispatched by +// cmd/root.go through WriteTypedErrorEnvelope. func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { if err.Detail == nil { return @@ -95,12 +85,8 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { // 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. +// Deprecated: construct a typed *errs.XxxError directly +// (e.g. errs.NewValidationError, errs.NewInternalError). func Errorf(code int, errType, format string, args ...any) *ExitError { var err error for _, arg := range args { @@ -117,42 +103,26 @@ func Errorf(code int, errType, format string, args ...any) *ExitError { } // 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. +// "validation"). The legacy envelope emits only `type`+`message`; for +// `subtype` / `param` extension fields, construct a typed +// *errs.ValidationError directly. func ErrValidation(format string, args ...any) *ExitError { return Errorf(ExitValidation, "validation", format, args...) } // 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. +// New code should construct a typed *errs.AuthenticationError directly; +// the typed envelope emits the canonical `type: "authentication"`. +// Migrating an existing call site flips a user-visible wire field. func ErrAuth(format string, args ...any) *ExitError { return Errorf(ExitAuth, "auth", format, args...) } // 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. +// The legacy envelope emits only `type`+`message`; for `subtype` +// ("transport" / "timeout" / "tls" / "dns") and retryable hint extension +// fields, construct a typed *errs.NetworkError directly. func ErrNetwork(format string, args ...any) *ExitError { return Errorf(ExitNetwork, "network", format, args...) } @@ -160,14 +130,9 @@ func ErrNetwork(format string, args ...any) *ExitError { // 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. +// Deprecated: route through errclass.BuildAPIError, which emits typed +// *errs.PermissionError / *errs.AuthenticationError / etc. with +// MissingScopes, ConsoleURL, and Identity at the source. func ErrAPI(larkCode int, msg string, detail any) *ExitError { exitCode, errType, hint := ClassifyLarkError(larkCode, msg) if errType == "permission" { @@ -187,12 +152,8 @@ 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. +// Deprecated: construct a typed *errs.XxxError directly and set its Hint +// field; the typed envelope promotes Problem.Hint to the wire. func ErrWithHint(code int, errType, msg, hint string) *ExitError { return &ExitError{ Code: code, @@ -201,15 +162,10 @@ 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. +// The predicate-command silent-exit signal: stdout has already been +// written and the caller wants the matching exit code without a stderr +// envelope (e.g. `auth check` emitting its JSON result and then exiting +// non-zero on a no-token state). Outside the typed-envelope contract. func ErrBare(code int) *ExitError { return &ExitError{Code: code} } @@ -220,8 +176,21 @@ func ErrBare(code int) *ExitError { // (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). +// Two-stage write: +// +// 1. Serialize the envelope into an in-memory buffer. If serialization +// fails, return false so the dispatcher falls back to the legacy +// envelope path; nothing is written to w. +// 2. Best-effort write of the serialized bytes to w. A partial write is +// accepted (return value still true): the typed exit code has already +// been determined upstream by handleRootError calling ExitCodeOf(err) +// before this writer runs, so a torn envelope on stderr must not +// downgrade the caller's typed exit (3/4/6/10) to plain 1. Consumers +// parse-or-skip on malformed JSON. +// +// Returns true when err was a typed error and serialization succeeded. +// Returns false only when err carries no Problem (caller should fall back +// to WriteErrorEnvelope) or when JSON encoding itself failed. func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool { typed, ok := errs.UnwrapTypedError(err) if !ok { @@ -242,12 +211,11 @@ func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool { // 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 - } + // Best-effort write. Partial-write does not downgrade the success status: + // the dispatcher has already captured ExitCodeOf(err) before calling us, + // and a torn stderr is preferable to falling through to the plain + // "Error:" path with exit 1. + _, _ = w.Write(buf.Bytes()) return true } diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go index ab4a5078..9d4fe9e6 100644 --- a/internal/output/errors_test.go +++ b/internal/output/errors_test.go @@ -7,9 +7,47 @@ import ( "bytes" "encoding/json" "errors" + "io" "testing" + + "github.com/larksuite/cli/errs" ) +// failingWriter writes up to limit bytes then returns io.ErrShortWrite on +// the write that would push past the limit. Used to simulate a stderr that +// dies mid-envelope. +type failingWriter struct { + limit int + n int +} + +func (f *failingWriter) Write(p []byte) (int, error) { + if f.n+len(p) > f.limit { + canWrite := f.limit - f.n + if canWrite < 0 { + canWrite = 0 + } + f.n += canWrite + return canWrite, io.ErrShortWrite + } + f.n += len(p) + return len(p), nil +} + +// TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus pins that +// when serialization succeeds but the underlying write fails mid-envelope, +// WriteTypedErrorEnvelope returns true so the dispatcher does NOT fall +// through to the legacy "Error:" path and clobber the typed exit code with +// 1. Exit code is preserved separately by handleRootError computing +// ExitCodeOf(err) before the write. +func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T) { + err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired") + w := &failingWriter{limit: 20} // dies mid-envelope + if ok := WriteTypedErrorEnvelope(w, err, "user"); !ok { + t.Error("partial write must return true; exit code is preserved separately") + } +} + func TestWriteErrorEnvelope_WithNotice(t *testing.T) { // Set up PendingNotice origNotice := PendingNotice @@ -119,11 +157,11 @@ 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. +// TestErrValidation_LegacyExitErrorShape pins the 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. Typed +// *errs.ValidationError carries the extension fields when needed. func TestErrValidation_LegacyExitErrorShape(t *testing.T) { err := ErrValidation("bad arg: %s", "x") @@ -163,7 +201,7 @@ func TestErrValidation_LegacyExitErrorShape(t *testing.T) { } } -// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for +// TestErrNetwork_LegacyExitErrorShape pins the 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) { diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index 62a5e057..892c2817 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -31,10 +31,15 @@ const ( LarkErrUserNotAuthorized = 230027 // user not authorized // App credential / status. - LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect - LarkErrAppNotInUse = 99991662 // app is disabled or not installed in this tenant + LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect (Open API) + LarkErrAppNotInUse = 99991662 // app is disabled in this tenant LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation + // TAT-endpoint variant of the "wrong app credentials" condition. + // /open-apis/auth/v3/tenant_access_token/internal returns code 10014 + // ("app secret invalid") instead of 99991543 when the secret is wrong. + LarkErrTATInvalidSecret = 10014 + // Rate limit. LarkErrRateLimit = 99991400 // request frequency limit exceeded @@ -94,14 +99,15 @@ var legacyHints = map[int]string{ LarkErrATInvalid: "run: lark-cli auth login to re-authorize", LarkErrTokenExpired: "run: lark-cli auth login to re-authorize", - 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", + LarkErrAppScopeNotEnabled: "the app developer must apply for the required scope(s) at the developer console", + LarkErrTokenNoPermission: "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued", + LarkErrUserScopeInsufficient: "run `lark-cli auth login` to re-authorize the user with the updated scope set", + LarkErrUserNotAuthorized: "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy", - 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", + LarkErrAppCredInvalid: "run `lark-cli config init` to set valid app_id and app_secret", + LarkErrTATInvalidSecret: "run `lark-cli config init` to set valid app_id and app_secret", + LarkErrAppNotInUse: "ask the tenant admin to re-enable the app in the Lark admin console", + LarkErrAppUnauthorized: "ask the tenant admin to check the app's install status in the Lark admin console", LarkErrRateLimit: "please try again later", LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests", @@ -117,32 +123,18 @@ var legacyHints = map[int]string{ // 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: +// Classification is sourced from errclass.LookupCodeMeta (the single source +// of truth). exitCode follows legacyExitCode below, which differs from +// ExitCodeForCategory in two preserved-legacy quirks: Authorization + +// permission subtypes return ExitAPI (legacy treated "permission" as +// exit 1), and Config returns ExitAuth (legacy bundled "check +// app_id/secret" under exit 3). errType maps to a legacy short string; +// unknown subtypes fall back to "api_error". Unknown codes classify as +// (ExitAPI, "api_error", ""). // -// - 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. +// Deprecated: route Lark API responses through errclass.BuildAPIError, +// which emits a typed *errs.XxxError with Category, Subtype, and +// identity-aware extension fields populated at the source. func ClassifyLarkError(code int, msg string) (int, string, string) { meta, ok := errclass.LookupCodeMeta(code) if !ok { @@ -180,7 +172,7 @@ func legacyExitCode(cat errs.Category, sub errs.Subtype) int { errs.SubtypeTokenScopeInsufficient: return ExitAPI case errs.SubtypeAppUnavailable, - errs.SubtypeAppNotInstalled: + errs.SubtypeAppDisabled: return ExitAuth } return ExitAPI @@ -206,7 +198,7 @@ func legacyErrType(cat errs.Category, sub errs.Subtype) string { errs.SubtypeTokenScopeInsufficient: return "permission" case errs.SubtypeAppUnavailable, - errs.SubtypeAppNotInstalled: + errs.SubtypeAppDisabled: return "app_status" } return "permission" diff --git a/internal/registry/helpers.go b/internal/registry/helpers.go index 6f564adf..39668f2e 100644 --- a/internal/registry/helpers.go +++ b/internal/registry/helpers.go @@ -38,6 +38,45 @@ func GetStrSliceFromMap(m map[string]interface{}, key string) []string { return result } +// DeclaredScopesForMethod returns the scopes declared by a method's +// from_meta entry for the given identity. Prefers the explicit +// `requiredScopes` field when present; otherwise returns the single +// recommended scope from `scopes` (or the first scope as a final fallback). +// Returns nil when the method has no scope information. +func DeclaredScopesForMethod(method map[string]interface{}, identity string) []string { + if method == nil { + return nil + } + if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { + out := make([]string, 0, len(requiredRaw)) + for _, v := range requiredRaw { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + if len(out) > 0 { + return out + } + } + rawScopes, _ := method["scopes"].([]interface{}) + if len(rawScopes) == 0 { + return nil + } + recommended := SelectRecommendedScope(rawScopes, identity) + if recommended == "" { + for _, raw := range rawScopes { + if s, ok := raw.(string); ok && s != "" { + recommended = s + break + } + } + } + if recommended == "" { + return nil + } + return []string{recommended} +} + // SelectRecommendedScope selects the known scope with the highest priority score // (higher = more recommended / least privilege). // Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes. diff --git a/lint/errscontract/rule_build_api_error_arms.go b/lint/errscontract/rule_build_api_error_arms.go new file mode 100644 index 00000000..282f9556 --- /dev/null +++ b/lint/errscontract/rule_build_api_error_arms.go @@ -0,0 +1,276 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// canonicalCategories enumerates every taxonomy Category that BuildAPIError +// must route. Kept in sync with errs/category.go. The lint refuses to +// silently accept the omission of a new Category — when the taxonomy grows, +// either BuildAPIError gets an explicit arm or this list is updated +// consciously (drawing a reviewer's attention). +var canonicalCategories = []string{ + "CategoryValidation", + "CategoryAuthentication", + "CategoryAuthorization", + "CategoryConfig", + "CategoryNetwork", + "CategoryAPI", + "CategoryPolicy", + "CategoryInternal", + "CategoryConfirmation", +} + +// CheckBuildAPIErrorArms enforces that the BuildAPIError switch in +// internal/errclass/classify.go (a) covers every Category in the canonical +// taxonomy and (b) has a `default` arm that fail-closes to an InternalError +// envelope — never returns nil and never falls through to emit an empty +// Problem on the wire. +// +// Scope: only the canonical classify.go file. Other switch statements on +// Category in callers (e.g. UI rendering) intentionally remain free-form. +// +// Returns REJECT violations. +func CheckBuildAPIErrorArms(path, src string) []Violation { + if !isClassifyPath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + var out []Violation + found := false + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name == nil || fn.Name.Name != "BuildAPIError" || fn.Body == nil { + continue + } + found = true + sw := findCategorySwitch(fn.Body) + if sw == nil { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(fn.Pos()).Line, + Message: "BuildAPIError has no Category switch — typed routing is the entire purpose of this function", + Suggestion: "restore the `switch meta.Category { case errs.CategoryX: ...; default: }` " + + "structure", + }) + continue + } + out = append(out, checkSwitchArms(path, fset, sw)...) + } + if !found { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: 1, + Message: "BuildAPIError function not found in classify.go — the typed-routing entry point must exist on this file", + Suggestion: "define `func BuildAPIError(resp map[string]any, cc ClassifyContext) error` with the canonical Category switch", + }) + } + return out +} + +// isClassifyPath matches both repo-relative ("internal/errclass/classify.go") +// and slashed paths that contain the same suffix when scanning nested roots. +func isClassifyPath(path string) bool { + p := strings.ReplaceAll(path, "\\", "/") + return p == "internal/errclass/classify.go" || strings.HasSuffix(p, "/internal/errclass/classify.go") +} + +// findCategorySwitch returns the first switch statement inside body whose +// arms reference `errs.Category*` selectors. Returns nil if no such switch +// exists. The shallow scan is sufficient — BuildAPIError contains exactly +// one taxonomy switch in production. +func findCategorySwitch(body *ast.BlockStmt) *ast.SwitchStmt { + var found *ast.SwitchStmt + ast.Inspect(body, func(n ast.Node) bool { + if found != nil { + return false + } + sw, ok := n.(*ast.SwitchStmt) + if !ok || sw.Body == nil { + return true + } + if switchMentionsCategory(sw) { + found = sw + return false + } + return true + }) + return found +} + +// switchMentionsCategory reports whether sw has at least one arm with an +// `errs.Category*` case expression. This is the cheap heuristic that +// identifies the canonical taxonomy switch without depending on type info. +func switchMentionsCategory(sw *ast.SwitchStmt) bool { + for _, stmt := range sw.Body.List { + cc, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range cc.List { + if categoryName(expr) != "" { + return true + } + } + } + return false +} + +// categoryName returns the `Category*` selector name (e.g. "CategoryAPI") +// for an `errs.Category*` expression, or "" when expr is not such a +// selector. Also accepts a bare `Category*` ident for an in-package +// switch (rare but possible). +func categoryName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.SelectorExpr: + if x, ok := t.X.(*ast.Ident); ok && x.Name == "errs" && t.Sel != nil && + strings.HasPrefix(t.Sel.Name, "Category") { + return t.Sel.Name + } + case *ast.Ident: + if strings.HasPrefix(t.Name, "Category") { + return t.Name + } + } + return "" +} + +// checkSwitchArms validates two invariants against the located switch: +// +// 1. Every Category in canonicalCategories appears as a case expression. +// 2. The switch has a default arm whose body returns a non-nil expression +// (i.e. fails closed to InternalError). +func checkSwitchArms(path string, fset *token.FileSet, sw *ast.SwitchStmt) []Violation { + covered := map[string]struct{}{} + var defaultArm *ast.CaseClause + for _, stmt := range sw.Body.List { + cc, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + if cc.List == nil { + defaultArm = cc + continue + } + for _, expr := range cc.List { + if name := categoryName(expr); name != "" { + covered[name] = struct{}{} + } + } + } + + var out []Violation + for _, cat := range canonicalCategories { + if _, ok := covered[cat]; ok { + continue + } + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(sw.Pos()).Line, + Message: "BuildAPIError switch is missing explicit arm for errs." + cat, + Suggestion: "add a case clause for errs." + cat + " that routes to the matching typed *Error; " + + "the canonical taxonomy is fixed in errs/category.go and every Category must be handled", + }) + } + + if defaultArm == nil { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(sw.Pos()).Line, + Message: "BuildAPIError switch has no default arm — unknown Category would fall through and emit an empty Problem", + Suggestion: "add `default:` that fail-closes to `&errs.InternalError{Problem: ...SubtypeSDKError...}` " + + "so unrecognised Category values cannot produce a wire-invalid envelope", + }) + } else if !defaultReturnsInternalError(defaultArm) { + out = append(out, Violation{ + Rule: "build_api_error_arms", + Action: ActionReject, + File: path, + Line: fset.Position(defaultArm.Pos()).Line, + Message: "BuildAPIError default arm returns nil or has no return — must fail closed to InternalError", + Suggestion: "return `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` " + + "so an unrecognised Category never silently drops the failure", + }) + } + return out +} + +// defaultReturnsInternalError checks the default arm's body has a return +// statement whose first result is *errs.InternalError — either constructed +// via `&errs.InternalError{...}` composite literal or `errs.NewInternalError(...)` +// constructor. Accepts both selector form (`errs.InternalError`) and bare +// identifier (`InternalError`) so unit-test fixtures with a local stub +// package match. The BuildAPIError default arm MUST fail closed to +// InternalError; other typed errors (APIError, etc.) silently drop the +// "unknown Category" signal and are rejected by this rule. +func defaultReturnsInternalError(cc *ast.CaseClause) bool { + for _, stmt := range cc.Body { + ret, ok := stmt.(*ast.ReturnStmt) + if !ok || len(ret.Results) == 0 { + continue + } + if isInternalErrorReturn(ret.Results[0]) { + return true + } + } + return false +} + +func isInternalErrorReturn(expr ast.Expr) bool { + switch e := expr.(type) { + case *ast.UnaryExpr: + // &errs.InternalError{...} or &InternalError{...} + if e.Op != token.AND { + return false + } + if cl, ok := e.X.(*ast.CompositeLit); ok { + return isInternalErrorType(cl.Type) + } + case *ast.CompositeLit: + // errs.InternalError{...} or InternalError{...} (value, rare for errors) + return isInternalErrorType(e.Type) + case *ast.CallExpr: + // errs.NewInternalError(...) or NewInternalError(...) + return isNewInternalErrorCall(e.Fun) + } + return false +} + +func isInternalErrorType(t ast.Expr) bool { + switch x := t.(type) { + case *ast.SelectorExpr: + return x.Sel.Name == "InternalError" + case *ast.Ident: + return x.Name == "InternalError" + } + return false +} + +func isNewInternalErrorCall(fn ast.Expr) bool { + switch x := fn.(type) { + case *ast.SelectorExpr: + return x.Sel.Name == "NewInternalError" + case *ast.Ident: + return x.Name == "NewInternalError" + } + return false +} diff --git a/lint/errscontract/rule_builder_immutable.go b/lint/errscontract/rule_builder_immutable.go new file mode 100644 index 00000000..4591399b --- /dev/null +++ b/lint/errscontract/rule_builder_immutable.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// CheckBuilderImmutable enforces builder immutability: a `With*` method on +// a typed *Error must not stash a caller-provided slice or map directly +// into a receiver field. The caller can later mutate the slice/map +// (append, delete) and silently corrupt the already-emitted typed envelope. +// +// Required shape — defensive clone: +// +// func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { +// e.MissingScopes = slices.Clone(scopes) +// return e +// } +// +// Violating shape — raw assignment: +// +// func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { +// e.MissingScopes = scopes +// return e +// } +// +// Detection strategy (AST-only, no type info): +// - Method name starts with "With" and takes at least one parameter +// - One parameter is a slice (`[]T`), variadic (`...T`), or map (`map[K]V`) +// - The method body contains `e. = ` where +// is exactly the slice/map parameter, with no slices.Clone / maps.Clone +// wrapper. +// +// Scope: errs/ package files (typed builders live there). +// +// Returns REJECT violations. +func CheckBuilderImmutable(path, src string) []Violation { + if !isErrsPackagePath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + var out []Violation + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || fn.Body == nil { + continue + } + if fn.Name == nil || !strings.HasPrefix(fn.Name.Name, "With") { + continue + } + // Only fire on methods whose receiver is a typed *Error from this package. + recvType := receiverTypeName(fn.Recv.List[0].Type) + if recvType == "" || !strings.HasSuffix(recvType, "Error") || recvType == "Error" { + continue + } + refParams := collectReferenceTypeParams(fn.Type) + if len(refParams) == 0 { + continue + } + out = append(out, scanBuilderBody(path, fset, fn, refParams)...) + } + return out +} + +// collectReferenceTypeParams returns the names of parameters whose type +// is a slice, variadic, or map (the reference-mutable shapes the rule +// guards). Pointer-to-slice / pointer-to-map are also considered. +func collectReferenceTypeParams(ft *ast.FuncType) map[string]struct{} { + out := map[string]struct{}{} + if ft.Params == nil { + return out + } + for _, field := range ft.Params.List { + if !isReferenceType(field.Type) { + continue + } + for _, n := range field.Names { + if n.Name != "" && n.Name != "_" { + out[n.Name] = struct{}{} + } + } + } + return out +} + +// isReferenceType reports whether expr names a slice, variadic, or map. +// Pointer-to-slice / map are also reference-typed for our purposes. +func isReferenceType(expr ast.Expr) bool { + switch t := expr.(type) { + case *ast.ArrayType: + // nil Len → slice (`[]T`). Fixed-length arrays are value types. + return t.Len == nil + case *ast.MapType: + return true + case *ast.Ellipsis: + return true + case *ast.StarExpr: + return isReferenceType(t.X) + } + return false +} + +// scanBuilderBody walks fn.Body and emits a violation for each +// `recv. = ` assignment whose RHS is a bare reference- +// typed parameter (not wrapped in slices.Clone / maps.Clone). +func scanBuilderBody(path string, fset *token.FileSet, fn *ast.FuncDecl, refParams map[string]struct{}) []Violation { + var out []Violation + recvName := "" + if len(fn.Recv.List[0].Names) > 0 { + recvName = fn.Recv.List[0].Names[0].Name + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + assign, ok := n.(*ast.AssignStmt) + if !ok || (assign.Tok != token.ASSIGN && assign.Tok != token.DEFINE) { + return true + } + if len(assign.Lhs) != 1 || len(assign.Rhs) != 1 { + return true + } + // LHS must be `recv.Field`. + sel, ok := assign.Lhs[0].(*ast.SelectorExpr) + if !ok { + return true + } + if recvName != "" && !isIdent(sel.X, recvName) { + return true + } + // RHS must be a bare reference-typed parameter ident with no + // defensive Clone wrapper. + paramID, ok := assign.Rhs[0].(*ast.Ident) + if !ok { + return true + } + if _, isRef := refParams[paramID.Name]; !isRef { + return true + } + fieldName := "" + if sel.Sel != nil { + fieldName = sel.Sel.Name + } + out = append(out, Violation{ + Rule: "builder_immutable", + Action: ActionReject, + File: path, + Line: fset.Position(assign.Pos()).Line, + Message: fn.Name.Name + " stashes caller-owned " + paramID.Name + " into " + fieldName + " without defensive copy", + Suggestion: "wrap the assignment with slices.Clone / maps.Clone (e.g. `" + + sel.Sel.Name + " = slices.Clone(" + paramID.Name + ")`); raw assignment lets the caller mutate the already-emitted typed envelope", + }) + return true + }) + return out +} diff --git a/lint/errscontract/rule_new_invariants_test.go b/lint/errscontract/rule_new_invariants_test.go new file mode 100644 index 00000000..c82fa3b3 --- /dev/null +++ b/lint/errscontract/rule_new_invariants_test.go @@ -0,0 +1,600 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "strings" + "testing" +) + +// Tests for the four typed-error invariant rules: +// - CheckNilSafeError +// - CheckUnwrapSymmetry +// - CheckBuilderImmutable +// - CheckBuildAPIErrorArms +// +// Each rule gets a "rejects bad shape" + "accepts compliant shape" pair so +// future regressions reveal themselves immediately. Fixtures use the +// minimal canonical Problem-embedder shape and run through the public +// CheckXxx entry points — no internal helpers are exercised directly. + +// =============================== CheckNilSafeError =========================== + +func TestCheckNilSafeError_FlagsMissingOverride(t *testing.T) { + // BadError embeds Problem by value but defines no own Error() — + // the promoted Error() panics on a typed-nil interface holder. + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem +} +` + v := CheckNilSafeError("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "BadError") { + t.Errorf("message must name the type: %s", v[0].Message) + } +} + +func TestCheckNilSafeError_FlagsMissingNilGuard(t *testing.T) { + // Error() exists but the first statement is not the nil-receiver guard. + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem +} + +func (e *BadError) Error() string { + return e.Problem.Error() +} +` + v := CheckNilSafeError("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "nil-receiver guard") { + t.Errorf("message should call out the missing nil guard: %s", v[0].Message) + } +} + +func TestCheckNilSafeError_AcceptsCompliantOverride(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type GoodError struct { + Problem +} + +func (e *GoodError) Error() string { + if e == nil { + return "" + } + return e.Problem.Error() +} +` + v := CheckNilSafeError("errs/types.go", src) + if len(v) != 0 { + t.Errorf("compliant nil-safe Error() must pass, got: %+v", v) + } +} + +func TestCheckNilSafeError_ScopedToErrsPackage(t *testing.T) { + // Same violating fixture outside errs/ — must NOT fire (the typed + // taxonomy is errs/-only). + src := `package custom + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem +} +` + v := CheckNilSafeError("internal/custom/x.go", src) + if len(v) != 0 { + t.Errorf("CheckNilSafeError must scope to errs/ only, got: %+v", v) + } +} + +// ============================== CheckUnwrapSymmetry ========================== + +func TestCheckUnwrapSymmetry_FlagsMissingUnwrap(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem + Cause error +} +` + v := CheckUnwrapSymmetry("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "BadError") { + t.Errorf("message must name the type: %s", v[0].Message) + } +} + +func TestCheckUnwrapSymmetry_FlagsUnwrapWithoutNilGuard(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type BadError struct { + Problem + Cause error +} + +func (e *BadError) Unwrap() error { + return e.Cause +} +` + v := CheckUnwrapSymmetry("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "nil-receiver guard") { + t.Errorf("message should call out the missing nil guard: %s", v[0].Message) + } +} + +func TestCheckUnwrapSymmetry_AcceptsCompliantUnwrap(t *testing.T) { + src := `package errs + +type Problem struct{} +func (p *Problem) Error() string { return "" } + +type GoodError struct { + Problem + Cause error +} + +func (e *GoodError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} +` + v := CheckUnwrapSymmetry("errs/types.go", src) + if len(v) != 0 { + t.Errorf("compliant Unwrap() must pass, got: %+v", v) + } +} + +// ============================= CheckBuilderImmutable ========================= + +func TestCheckBuilderImmutable_FlagsBareSliceAssignment(t *testing.T) { + src := `package errs + +type Problem struct{} + +type PermissionError struct { + Problem + MissingScopes []string +} + +func (e *PermissionError) WithMissingScopes(scopes []string) *PermissionError { + e.MissingScopes = scopes + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "WithMissingScopes") || !strings.Contains(v[0].Message, "MissingScopes") { + t.Errorf("message should name the builder and field: %s", v[0].Message) + } +} + +func TestCheckBuilderImmutable_FlagsBareVariadicAssignment(t *testing.T) { + // Variadic slice has the same aliasing hazard. + src := `package errs + +type Problem struct{} + +type PermissionError struct { + Problem + MissingScopes []string +} + +func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { + e.MissingScopes = scopes + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } +} + +func TestCheckBuilderImmutable_FlagsBareMapAssignment(t *testing.T) { + src := `package errs + +type Problem struct{} + +type APIError struct { + Problem + Detail map[string]any +} + +func (e *APIError) WithDetail(detail map[string]any) *APIError { + e.Detail = detail + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "WithDetail") { + t.Errorf("message should name the builder: %s", v[0].Message) + } +} + +func TestCheckBuilderImmutable_AcceptsClonedSlice(t *testing.T) { + src := `package errs + +import "slices" + +type Problem struct{} + +type PermissionError struct { + Problem + MissingScopes []string +} + +func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError { + e.MissingScopes = slices.Clone(scopes) + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 0 { + t.Errorf("slices.Clone wrap must pass, got: %+v", v) + } +} + +func TestCheckBuilderImmutable_AcceptsClonedMap(t *testing.T) { + src := `package errs + +import "maps" + +type Problem struct{} + +type APIError struct { + Problem + Detail map[string]any +} + +func (e *APIError) WithDetail(detail map[string]any) *APIError { + e.Detail = maps.Clone(detail) + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 0 { + t.Errorf("maps.Clone wrap must pass, got: %+v", v) + } +} + +func TestCheckBuilderImmutable_IgnoresScalarSetters(t *testing.T) { + // Scalar / string setters are not reference-typed; the rule must not + // false-positive on them. + src := `package errs + +type Problem struct{} + +type ConfigError struct { + Problem + Field string +} + +func (e *ConfigError) WithField(field string) *ConfigError { + e.Field = field + return e +} + +func (e *ConfigError) WithCode(code int) *ConfigError { + e.Code = code + return e +} +` + v := CheckBuilderImmutable("errs/types.go", src) + if len(v) != 0 { + t.Errorf("scalar setters must not fire, got: %+v", v) + } +} + +// ============================ CheckBuildAPIErrorArms ========================= + +func TestCheckBuildAPIErrorArms_FlagsMissingCategory(t *testing.T) { + // Switch is missing the CategoryConfirmation arm. + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + default: + return &errs.InternalError{} + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (missing CategoryConfirmation), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "CategoryConfirmation") { + t.Errorf("message should name the missing Category: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_FlagsMissingDefault(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + } + return nil +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (missing default arm), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "no default arm") { + t.Errorf("message should call out the missing default: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_FlagsNilReturningDefault(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return nil + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (default returns nil), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "default arm") { + t.Errorf("message should call out the default arm: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_AcceptsCompliantSwitch(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return &errs.InternalError{} + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 0 { + t.Errorf("compliant switch must pass, got: %+v", v) + } +} + +func TestCheckBuildAPIErrorArms_RejectsWrongCategoryDefault(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return &errs.APIError{} + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation (wrong-type default), got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "InternalError") { + t.Errorf("violation must call out InternalError requirement: %s", v[0].Message) + } +} + +func TestCheckBuildAPIErrorArms_AcceptsNewInternalErrorConstructor(t *testing.T) { + src := `package errclass + +import "github.com/larksuite/cli/errs" + +type ClassifyContext struct{} + +func BuildAPIError(resp map[string]any, cc ClassifyContext) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return &errs.ValidationError{} + case errs.CategoryAuthentication: + return &errs.AuthenticationError{} + case errs.CategoryAuthorization: + return &errs.PermissionError{} + case errs.CategoryConfig: + return &errs.ConfigError{} + case errs.CategoryNetwork: + return &errs.NetworkError{} + case errs.CategoryAPI: + return &errs.APIError{} + case errs.CategoryPolicy: + return &errs.SecurityPolicyError{} + case errs.CategoryInternal: + return &errs.InternalError{} + case errs.CategoryConfirmation: + return &errs.ConfirmationRequiredError{} + default: + return errs.NewInternalError(errs.SubtypeSDKError, "unknown category") + } +} +` + v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src) + if len(v) != 0 { + t.Errorf("constructor form must be accepted, got: %+v", v) + } +} + +func TestCheckBuildAPIErrorArms_ScopedToClassifyFile(t *testing.T) { + // Identical violating shape outside the canonical path — must NOT fire. + src := `package custom + +import "github.com/larksuite/cli/errs" + +func BuildAPIError(resp map[string]any) error { + var cat errs.Category + switch cat { + case errs.CategoryValidation: + return nil + } + return nil +} +` + v := CheckBuildAPIErrorArms("internal/foo/other.go", src) + if len(v) != 0 { + t.Errorf("rule must scope to internal/errclass/classify.go, got: %+v", v) + } +} diff --git a/lint/errscontract/rule_nil_safe_error.go b/lint/errscontract/rule_nil_safe_error.go new file mode 100644 index 00000000..966fadf8 --- /dev/null +++ b/lint/errscontract/rule_nil_safe_error.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// CheckNilSafeError enforces that every typed *Error struct embedding +// Problem by value defines its own pointer-receiver Error() method whose +// first statement is a nil-receiver guard returning "". +// +// Why: the embedded Problem provides Error() via promotion, but a typed- +// nil interface holder (`var e *XxxError; var err error = e`) bypasses +// the promoted method's receiver guard and panics on err.Error(). +// Each typed wrapper therefore needs its own nil-safe override. +// +// Scope: errs/ package files. Unexported helper structs are skipped — +// they are not part of the public taxonomy. +// +// Returns REJECT violations. +func CheckNilSafeError(path, src string) []Violation { + if !isErrsPackagePath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + // Collect every exported struct embedding Problem-by-value. + embedders := collectProblemEmbedders(file) + if len(embedders) == 0 { + return nil + } + + // Find all Error() methods defined on those types (pointer or value receiver). + errorMethods := collectMethodsNamed(file, "Error") + + var out []Violation + for name, pos := range embedders { + fn, ok := errorMethods[name] + if !ok { + out = append(out, Violation{ + Rule: "nil_safe_error", + Action: ActionReject, + File: path, + Line: fset.Position(pos).Line, + Message: "typed error " + name + " embeds Problem by value but defines no own Error() — typed-nil holders will panic via promoted method", + Suggestion: "add `func (e *" + name + ") Error() string { if e == nil { return \"\" }; return e.Problem.Error() }` " + + "so an interface holding a typed-nil pointer returns \"\" instead of panicking", + }) + continue + } + if !hasNilReceiverGuard(fn) { + out = append(out, Violation{ + Rule: "nil_safe_error", + Action: ActionReject, + File: path, + Line: fset.Position(fn.Pos()).Line, + Message: "typed error " + name + ".Error() lacks `if e == nil { return \"\" }` nil-receiver guard", + Suggestion: "first statement of " + name + ".Error() must be the nil-receiver guard so typed-nil holders cannot panic", + }) + } + } + return out +} + +// collectProblemEmbedders returns the map of exported *Error struct names +// in the file that embed Problem (by value) → declaration position. +func collectProblemEmbedders(file *ast.File) map[string]token.Pos { + out := map[string]token.Pos{} + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + name := ts.Name.Name + if !ast.IsExported(name) || !strings.HasSuffix(name, "Error") || name == "Error" { + return true + } + if !embedsProblem(st) { + return true + } + out[name] = ts.Pos() + return true + }) + return out +} + +// collectMethodsNamed returns receiver-type name → FuncDecl for every +// method whose declared name matches methodName. The receiver type may +// be either `T` or `*T`; both forms are recorded under "T". +func collectMethodsNamed(file *ast.File, methodName string) map[string]*ast.FuncDecl { + out := map[string]*ast.FuncDecl{} + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 { + continue + } + if fn.Name == nil || fn.Name.Name != methodName { + continue + } + recv := receiverTypeName(fn.Recv.List[0].Type) + if recv == "" { + continue + } + out[recv] = fn + } + return out +} + +// receiverTypeName extracts T from a method receiver expression (`T` or `*T`). +func receiverTypeName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + if id, ok := t.X.(*ast.Ident); ok { + return id.Name + } + } + return "" +} + +// hasNilReceiverGuard reports whether the first statement of fn is the +// canonical `if e == nil { return ... }` guard. The receiver name is read +// from fn.Recv so the check is robust to renamed receivers. +func hasNilReceiverGuard(fn *ast.FuncDecl) bool { + if fn.Body == nil || len(fn.Body.List) == 0 { + return false + } + recvName := "" + if len(fn.Recv.List) > 0 && len(fn.Recv.List[0].Names) > 0 { + recvName = fn.Recv.List[0].Names[0].Name + } + if recvName == "" { + return false + } + ifs, ok := fn.Body.List[0].(*ast.IfStmt) + if !ok { + return false + } + bin, ok := ifs.Cond.(*ast.BinaryExpr) + if !ok || bin.Op != token.EQL { + return false + } + // Accept either `recv == nil` or `nil == recv`. + if !isIdent(bin.X, recvName) || !isIdent(bin.Y, "nil") { + if !isIdent(bin.Y, recvName) || !isIdent(bin.X, "nil") { + return false + } + } + // Body must contain a ReturnStmt (we don't require empty-string return — + // some types return a more specific sentinel; the contract is "return + // without dereferencing the nil receiver"). + if ifs.Body == nil { + return false + } + for _, stmt := range ifs.Body.List { + if _, ok := stmt.(*ast.ReturnStmt); ok { + return true + } + } + return false +} + +func isIdent(expr ast.Expr, name string) bool { + id, ok := expr.(*ast.Ident) + return ok && id.Name == name +} diff --git a/lint/errscontract/rule_unwrap_symmetry.go b/lint/errscontract/rule_unwrap_symmetry.go new file mode 100644 index 00000000..eb7a72a3 --- /dev/null +++ b/lint/errscontract/rule_unwrap_symmetry.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/parser" + "go/token" +) + +// CheckUnwrapSymmetry enforces that every typed *Error struct embedding +// Problem defines its own nil-safe Unwrap() method, mirroring the Error() +// nil-guard contract enforced by CheckNilSafeError. +// +// Why: typed errors carry a Cause field and downstream callers traverse +// it via errors.Unwrap / errors.Is. A typed-nil holder +// (`var e *XxxError; var err error = e`) would otherwise dispatch through +// the embedded Problem.Unwrap (or panic when none exists), bypassing the +// type's own intent. +// +// Scope: errs/ package files. Unexported helper structs are skipped. +// +// Returns REJECT violations. +func CheckUnwrapSymmetry(path, src string) []Violation { + if !isErrsPackagePath(path) { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + + embedders := collectProblemEmbedders(file) + if len(embedders) == 0 { + return nil + } + + unwrapMethods := collectMethodsNamed(file, "Unwrap") + + var out []Violation + for name, pos := range embedders { + fn, ok := unwrapMethods[name] + if !ok { + out = append(out, Violation{ + Rule: "unwrap_symmetry", + Action: ActionReject, + File: path, + Line: fset.Position(pos).Line, + Message: "typed error " + name + " embeds Problem but defines no own Unwrap() — typed-nil holders cannot be safely traversed by errors.Unwrap", + Suggestion: "add `func (e *" + name + ") Unwrap() error { if e == nil { return nil }; return e.Cause }` " + + "so the typed envelope and the standard errors.Is/Unwrap traversal stay in sync", + }) + continue + } + if !hasNilReceiverGuard(fn) { + out = append(out, Violation{ + Rule: "unwrap_symmetry", + Action: ActionReject, + File: path, + Line: fset.Position(fn.Pos()).Line, + Message: "typed error " + name + ".Unwrap() lacks `if e == nil { return nil }` nil-receiver guard", + Suggestion: "first statement of " + name + ".Unwrap() must be the nil-receiver guard so errors.Unwrap on a typed-nil holder returns nil instead of panicking", + }) + } + } + return out +} diff --git a/lint/errscontract/runner.go b/lint/errscontract/runner.go index 5d494575..f28fd32b 100644 --- a/lint/errscontract/runner.go +++ b/lint/errscontract/runner.go @@ -21,7 +21,14 @@ func RunAllWithNames(path, src string, allowlist, nameset map[string]struct{}) [ // CheckProblemEmbed fires on errs/ files only (caller may also enforce parity // across directory via CheckErrsContract). out = append(out, CheckProblemEmbed(path, src)...) + // The next three rules also scope to errs/ files internally — they + // guard the typed wrappers that live exclusively in this package. + out = append(out, CheckNilSafeError(path, src)...) + out = append(out, CheckUnwrapSymmetry(path, src)...) + out = append(out, CheckBuilderImmutable(path, src)...) } + // CheckBuildAPIErrorArms self-scopes to internal/errclass/classify.go. + out = append(out, CheckBuildAPIErrorArms(path, src)...) out = append(out, CheckNoRegistrar(path, src)...) out = append(out, CheckAdHocSubtype(path, src)...) out = append(out, CheckTypedErrorCompleteness(path, src)...) diff --git a/lint/errscontract/scan.go b/lint/errscontract/scan.go index 2149e45f..bb920d85 100644 --- a/lint/errscontract/scan.go +++ b/lint/errscontract/scan.go @@ -106,6 +106,11 @@ func ScanRepo(root string) ([]Violation, error) { all = append(all, CheckNoRegistrar(rel, string(src))...) all = append(all, CheckAdHocSubtype(rel, string(src))...) all = append(all, CheckTypedErrorCompleteness(rel, string(src))...) + // Typed-error invariants — self-scope to errs/ + classify.go. + all = append(all, CheckNilSafeError(rel, string(src))...) + all = append(all, CheckUnwrapSymmetry(rel, string(src))...) + all = append(all, CheckBuilderImmutable(rel, string(src))...) + all = append(all, CheckBuildAPIErrorArms(rel, string(src))...) if allowlist != nil && !isErrsScope(rel) { // CheckDeclaredSubtype does not fire inside the errs/ package itself — that // package defines the Subtype type and its constructors take diff --git a/lint/errscontract/scan_test.go b/lint/errscontract/scan_test.go index 7ffc63cd..f51d3453 100644 --- a/lint/errscontract/scan_test.go +++ b/lint/errscontract/scan_test.go @@ -76,10 +76,10 @@ const ( SubtypeMissingScope Subtype = "missing_scope" ) `, - "errs/subtypes_service_task.go": `package errs + "errs/subtypes_extra.go": `package errs const ( - SubtypeTaskInvalidParams Subtype = "task_invalid_params" + SubtypeExtraExample Subtype = "extra_example" ) `, }) @@ -87,12 +87,12 @@ const ( if err != nil { t.Fatalf("LoadSubtypeAllowlists: %v", err) } - for _, v := range []string{"missing_scope", "task_invalid_params"} { + for _, v := range []string{"missing_scope", "extra_example"} { if _, ok := values[v]; !ok { t.Errorf("values missing %q (across-file load broken)", v) } } - for _, n := range []string{"SubtypeMissingScope", "SubtypeTaskInvalidParams"} { + for _, n := range []string{"SubtypeMissingScope", "SubtypeExtraExample"} { if _, ok := names[n]; !ok { t.Errorf("names missing %q (across-file load broken)", n) } diff --git a/scripts/check-skill-wire-vocab.sh b/scripts/check-skill-wire-vocab.sh new file mode 100755 index 00000000..2aaecd0b --- /dev/null +++ b/scripts/check-skill-wire-vocab.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# §12.3: forward rule — any errs/ wire-shape change MUST be paired +# with a skills/ grep sweep in the same PR. +set -euo pipefail +PATTERN='"type"\s*:\s*"(auth_error|api_error|infra_error|missing_scope|command_denied|external_provider)"' +if git grep -E "$PATTERN" skills/ >/dev/null 2>&1; then + echo "[WIRE-VOCAB-DRIFT] skills/ contains legacy wire strings — see spec §12.3" >&2 + git grep -nE "$PATTERN" skills/ >&2 + exit 1 +fi +echo "skill wire-vocab clean." diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 19ea464a..74ded487 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -20,6 +20,7 @@ import ( "strings" "unicode/utf8" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" @@ -833,22 +834,38 @@ func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) m func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error { msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err) + detail := map[string]interface{}{ + "downloaded": downloaded, + "failed": failed, + } + if logID := baseAttachmentDownloadLogID(err); logID != "" { + detail["log_id"] = logID + } + const hint = "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists." + var exitErr *output.ExitError if errors.As(err, &exitErr) && exitErr.Detail != nil { - detail := map[string]interface{}{ - "downloaded": downloaded, - "failed": failed, - } - if logID := baseAttachmentDownloadLogID(err); logID != "" { - detail["log_id"] = logID - } return &output.ExitError{ Code: exitErr.Code, Detail: &output.ErrDetail{ Type: exitErr.Detail.Type, Code: exitErr.Detail.Code, Message: msg, - Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.", + Hint: hint, + Detail: detail, + }, + Err: err, + } + } + var netErr *errs.NetworkError + if errors.As(err, &netErr) { + return &output.ExitError{ + Code: output.ExitNetwork, + Detail: &output.ErrDetail{ + Type: "network", + Code: netErr.Code, + Message: msg, + Hint: hint, Detail: detail, }, Err: err, @@ -859,27 +876,29 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac Detail: &output.ErrDetail{ Type: "io", Message: msg, - Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.", - Detail: map[string]interface{}{ - "downloaded": downloaded, - "failed": failed, - }, + Hint: hint, + Detail: detail, }, Err: err, } } func baseAttachmentDownloadLogID(err error) string { + var netErr *errs.NetworkError + if errors.As(err, &netErr) { + if id := strings.TrimSpace(netErr.LogID); id != "" { + return id + } + } var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - return "" + if errors.As(err, &exitErr) && exitErr.Detail != nil { + if detail, ok := exitErr.Detail.Detail.(map[string]interface{}); ok { + if logID, _ := detail["log_id"].(string); logID != "" { + return strings.TrimSpace(logID) + } + } } - detail, ok := exitErr.Detail.Detail.(map[string]interface{}) - if !ok { - return "" - } - logID, _ := detail["log_id"].(string) - return strings.TrimSpace(logID) + return "" } func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool { diff --git a/shortcuts/calendar/helpers.go b/shortcuts/calendar/helpers.go index a61c04b0..b9511fea 100644 --- a/shortcuts/calendar/helpers.go +++ b/shortcuts/calendar/helpers.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -48,5 +48,5 @@ func rejectCalendarAutoBotFallback(runtime *common.RuntimeContext) error { msg := "calendar commands require a valid user login by default; when no valid user login state is available, auto identity falls back to bot and may operate on the bot calendar instead of your own. Run `lark-cli auth login --domain calendar` for your calendar, or rerun with `--as bot` if bot identity is intentional." hint := "restore user login: `lark-cli auth login --domain calendar`\nintentional bot usage: rerun with `--as bot`" - return output.ErrWithHint(output.ExitAuth, "calendar_user_login_required", msg, hint) + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", msg).WithHint("%s", hint) } diff --git a/shortcuts/common/mcp_client.go b/shortcuts/common/mcp_client.go index 5e87eb66..895aa2a4 100644 --- a/shortcuts/common/mcp_client.go +++ b/shortcuts/common/mcp_client.go @@ -14,8 +14,9 @@ import ( "github.com/google/uuid" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/util" ) @@ -34,7 +35,7 @@ func CallMCPTool(runtime *RuntimeContext, toolName string, args map[string]inter httpClient, err := runtime.Factory.HttpClient() if err != nil { - return nil, output.ErrNetwork("failed to get HTTP client: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %v", err).WithCause(err) } raw, err := DoMCPCall(runtime.Ctx(), httpClient, toolName, args, accessToken, MCPEndpoint(runtime.Config.Brand), runtime.IsBot()) @@ -49,7 +50,7 @@ func normalizeMCPToolResult(raw interface{}) (map[string]interface{}, error) { result := ExtractMCPResult(raw) if m, ok := result.(map[string]interface{}); ok { if errMsg, ok := m["error"].(string); ok && strings.TrimSpace(errMsg) != "" { - return nil, output.Errorf(output.ExitAPI, "mcp_error", "MCP: %s", errMsg) + return nil, errs.NewAPIError(errs.SubtypeUnknown, "MCP: %s", errMsg) } return m, nil } @@ -72,12 +73,12 @@ func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, ar jsonBody, err := json.Marshal(body) if err != nil { - return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to marshal MCP request body: %v", err) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal MCP request body: %v", err).WithCause(err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, mcpEndpoint, bytes.NewReader(jsonBody)) if err != nil { - return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to create MCP request: %v", err) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to create MCP request: %v", err).WithCause(err) } req.Header.Set("Content-Type", "application/json") if isBot { @@ -89,13 +90,13 @@ func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, ar resp, err := httpClient.Do(req) if err != nil { - return nil, output.ErrNetwork("MCP transport failed: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "MCP transport failed: %v", err).WithCause(err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, output.ErrNetwork("failed to read MCP response: %v", err) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to read MCP response: %v", err).WithCause(err) } if resp.StatusCode >= 400 { return nil, classifyMCPHTTPError(resp.StatusCode, resp.Status, respBody) @@ -103,7 +104,9 @@ func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, ar var data map[string]interface{} if err := json.Unmarshal(respBody, &data); err != nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "MCP returned non-JSON: %s", TruncateStr(string(respBody), mcpErrorBodyLimit)) + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "MCP returned non-JSON: %s", TruncateStr(string(respBody), mcpErrorBodyLimit)). + WithCause(err) } if errObj, ok := data["error"]; ok { @@ -119,16 +122,19 @@ func classifyMCPHTTPError(statusCode int, status string, body []byte) error { if errObj, ok := payload["error"]; ok { return classifyMCPPayloadError(errObj) } - if code, msg, detail, ok := extractMCPBusinessError(payload); ok { - return output.ErrAPI(code, fmt.Sprintf("MCP HTTP %d %s: [%d] %s", statusCode, status, code, msg), detail) + if code, msg, ok := extractMCPBusinessError(payload); ok { + return errs.NewAPIError(errs.SubtypeUnknown, "MCP HTTP %d %s: [%d] %s", statusCode, status, code, msg).WithCode(code) } } bodyText := TruncateStr(strings.TrimSpace(string(body)), mcpErrorBodyLimit) if statusCode == http.StatusUnauthorized { - return output.ErrAuth("MCP HTTP %d %s: %s", statusCode, status, bodyText) + return errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "MCP HTTP %d %s: %s", statusCode, status, bodyText).WithCode(statusCode) } - return output.Errorf(output.ExitAPI, "api_error", "MCP HTTP %d %s: %s", statusCode, status, bodyText) + if statusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, "MCP HTTP %d %s: %s", statusCode, status, bodyText).WithCode(statusCode) + } + return errs.NewAPIError(errs.SubtypeUnknown, "MCP HTTP %d %s: %s", statusCode, status, bodyText).WithCode(statusCode) } func classifyMCPPayloadError(errObj interface{}) error { @@ -138,54 +144,45 @@ func classifyMCPPayloadError(errObj interface{}) error { msg = GetString(errMap, "msg") } if code, ok := util.ToFloat64(errMap["code"]); ok { - return output.ErrAPI(int(code), fmt.Sprintf("MCP: [%.0f] %s", code, msg), errMap) + // Route known Lark error codes through errclass so 99991668-style + // codes become typed (Authentication / Permission / ...) rather + // than generic APIError. Falls back to APIError for unknown codes. + payload := map[string]any{"code": int(code), "msg": msg, "error": errMap} + if classified := errclass.BuildAPIError(payload, errclass.ClassifyContext{}); classified != nil { + return classified + } + return errs.NewAPIError(errs.SubtypeUnknown, "MCP: [%.0f] %s", code, msg).WithCode(int(code)) } if msg != "" { - return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errMap) + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg)) } } if msg, ok := errObj.(string); ok && strings.TrimSpace(msg) != "" { - return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errObj) + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg)) } - return output.Errorf(output.ExitAPI, "api_error", "MCP returned an error response") + return errs.NewAPIError(errs.SubtypeUnknown, "MCP returned an error response") } -func classifyMCPMessageError(msg string, detail interface{}) error { +func classifyMCPMessageError(msg string) error { lower := strings.ToLower(msg) switch { case strings.Contains(lower, "unauthorized"), strings.Contains(lower, "access token"), strings.Contains(lower, "token invalid"), strings.Contains(lower, "token expired"): - return &output.ExitError{ - Code: output.ExitAuth, - Detail: &output.ErrDetail{ - Type: "auth", - Message: msg, - Hint: "run `lark-cli auth login` in the background to re-authorize. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", - Detail: detail, - }, - } + return errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "%s", msg). + WithHint("run `lark-cli auth login` in the background to re-authorize. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.") default: - code, errType, hint := output.ClassifyLarkError(0, msg) - return &output.ExitError{ - Code: code, - Detail: &output.ErrDetail{ - Type: errType, - Message: msg, - Hint: hint, - Detail: detail, - }, - } + return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg) } } -func extractMCPBusinessError(payload map[string]interface{}) (int, string, interface{}, bool) { +func extractMCPBusinessError(payload map[string]interface{}) (int, string, bool) { code, ok := util.ToFloat64(payload["code"]) if !ok || code == 0 { - return 0, "", nil, false + return 0, "", false } msg := GetString(payload, "msg") @@ -195,7 +192,7 @@ func extractMCPBusinessError(payload map[string]interface{}) (int, string, inter if msg == "" { msg = "unknown MCP error" } - return int(code), msg, payload["error"], true + return int(code), msg, true } func UnwrapMCPResult(v interface{}) interface{} { diff --git a/shortcuts/common/mcp_client_test.go b/shortcuts/common/mcp_client_test.go index 6a1b03a1..da021a22 100644 --- a/shortcuts/common/mcp_client_test.go +++ b/shortcuts/common/mcp_client_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" ) @@ -53,15 +54,15 @@ func TestDoMCPCallJSONRPCErrorUsesLarkClassification(t *testing.T) { } _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %v", err) + if err == nil { + t.Fatal("expected error, got nil") } - if exitErr.Code != output.ExitAuth { - t.Fatalf("expected auth exit code, got %d", exitErr.Code) + if got := output.ExitCodeOf(err); got != output.ExitAuth { + t.Fatalf("expected auth exit code (%d), got %d", output.ExitAuth, got) } - if exitErr.Detail == nil || exitErr.Detail.Type != "auth" { - t.Fatalf("expected auth detail, got %#v", exitErr.Detail) + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("expected *errs.AuthenticationError, got %T: %v", err, err) } } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index aa535af0..ea69cfd3 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -19,6 +19,7 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/client" @@ -681,24 +682,31 @@ func checkScopePrereqs(f *cmdutil.Factory, ctx context.Context, appID string, id // enhancePermissionError enriches a permission / auth error with the // shortcut's declared required scopes so the user knows exactly what to do. +// +// Detection is typed: an error qualifies when it (or any error in its +// Unwrap chain) is *errs.PermissionError, or — for legacy bridge paths — +// when it is an *output.ExitError carrying Detail.Type "permission" or +// "missing_scope". The previous implementation scanned the upstream +// message text for keywords like "permission" / "scope" / "unauthorized", +// which was brittle to canonical-message rewrites; routing on the typed +// shape decouples this helper from the wording. func enhancePermissionError(err error, requiredScopes []string) error { + var permErr *errs.PermissionError + if errors.As(err, &permErr) { + scopeDisplay := strings.Join(requiredScopes, ", ") + scopeArg := strings.Join(requiredScopes, " ") + hint := fmt.Sprintf( + "this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", + scopeDisplay, scopeArg) + permErr.Hint = hint + return err + } + var exitErr *output.ExitError if !errors.As(err, &exitErr) || exitErr.Detail == nil { return err } - - // Detect permission-related errors by type or message keywords. - isPermErr := exitErr.Detail.Type == "permission" || exitErr.Detail.Type == "missing_scope" - if !isPermErr { - lower := strings.ToLower(exitErr.Detail.Message) - for _, kw := range []string{"permission", "scope", "authorization", "unauthorized"} { - if strings.Contains(lower, kw) { - isPermErr = true - break - } - } - } - if !isPermErr { + if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "missing_scope" { return err } @@ -830,9 +838,11 @@ func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identi if len(missing) == 0 { return nil } - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "missing required scope(s): %s", strings.Join(missing, ", ")). + WithIdentity(string(as)). + WithMissingScopes(missing...). + WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")) } func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) { diff --git a/shortcuts/common/runner_scope_test.go b/shortcuts/common/runner_scope_test.go index 9d8620b0..9b1602d9 100644 --- a/shortcuts/common/runner_scope_test.go +++ b/shortcuts/common/runner_scope_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -44,51 +45,57 @@ func TestEnhancePermissionError_MissingScopeType(t *testing.T) { } } -func TestEnhancePermissionError_KeywordPermission(t *testing.T) { +// TestEnhancePermissionError_TypedPermissionErrorRouted pins typed routing: +// an *errs.PermissionError gets enhanced regardless of its Message text, +// decoupling this helper from canonical-message rewrites that would +// previously break the legacy keyword scan. +func TestEnhancePermissionError_TypedPermissionErrorRouted(t *testing.T) { scopes := []string{"drive:drive:read"} - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: "Permission denied for resource"}, + err := &errs.PermissionError{ + Problem: errs.Problem{ + Category: errs.CategoryAuthorization, + Subtype: errs.SubtypeMissingScope, + Message: "access denied: app cli_x has not applied for the required scope(s)", + }, } got := enhancePermissionError(err, scopes) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T", got) + var permErr *errs.PermissionError + if !errors.As(got, &permErr) { + t.Fatalf("expected *PermissionError, got %T", got) } - if !strings.Contains(exitErr.Detail.Hint, "drive:drive:read") { - t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + if !strings.Contains(permErr.Hint, "drive:drive:read") { + t.Errorf("hint %q missing scope info", permErr.Hint) } } -func TestEnhancePermissionError_KeywordScope(t *testing.T) { - scopes := []string{"task:task:read"} - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: "Insufficient scope for operation"}, - } - got := enhancePermissionError(err, scopes) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T", got) - } - if !strings.Contains(exitErr.Detail.Hint, "task:task:read") { - t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) - } -} - -func TestEnhancePermissionError_KeywordAuthorization(t *testing.T) { +// TestEnhancePermissionError_KeywordScanRemoved pins that an *output.ExitError +// whose Detail.Type is NOT "permission" / "missing_scope" is no longer +// matched by upstream-message keyword scan. This is the contract change in +// T15: typed routing replaces the brittle keyword scan, so canonical +// message rewrites cannot accidentally flip an unrelated api_error into +// the permission-enhancement path. +func TestEnhancePermissionError_KeywordScanRemoved(t *testing.T) { scopes := []string{"contact:contact:read"} - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: "Authorization required"}, + cases := []struct { + name string + msg string + }{ + {"permission keyword", "Permission denied for resource"}, + {"scope keyword", "Insufficient scope for operation"}, + {"authorization keyword", "Authorization required"}, + {"unauthorized keyword", "request unauthorized by server"}, } - got := enhancePermissionError(err, scopes) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T", got) - } - if !strings.Contains(exitErr.Detail.Hint, "contact:contact:read") { - t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: tc.msg}, + } + got := enhancePermissionError(err, scopes) + if got != err { + t.Errorf("expected original error returned (type=api_error must not match), got %T: %v", got, got) + } + }) } } @@ -111,13 +118,12 @@ func TestEnhancePermissionError(t *testing.T) { hintSubstr: "scope", }, { - name: "mcp_error with unauthorized keyword gets enhanced", + name: "mcp_error with unauthorized keyword not enhanced (keyword scan removed)", err: &output.ExitError{ Code: 1, Detail: &output.ErrDetail{Type: "mcp_error", Message: "request unauthorized by server"}, }, - wantHint: true, - hintSubstr: "scope", + wantHint: false, }, { name: "api_error without keyword not modified", @@ -189,6 +195,57 @@ func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) { } } +// TestCheckShortcutScopes_ReturnsTypedPermissionError pins that the local +// precheck — when it finds the issued token is missing required scopes — +// emits a typed *errs.PermissionError with Subtype MissingScope, the resolved +// Identity, and the deterministic MissingScopes set. AI/script consumers +// downstream rely on these structured fields instead of parsing the hint +// string. The Hint still carries the actionable `auth login --scope ...` +// command for human consumers. +func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) { + f := &cmdutil.Factory{ + Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{ + result: &credential.TokenResult{Token: "t", Scopes: "im:message:read calendar:calendar:read"}, + }, nil), + } + + required := []string{"im:message:read", "drive:drive:read", "docx:document:read"} + err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, required) + if err == nil { + t.Fatal("expected error when token is missing required scopes, got nil") + } + + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + if permErr.Category != errs.CategoryAuthorization { + t.Errorf("Category = %q, want %q", permErr.Category, errs.CategoryAuthorization) + } + if permErr.Subtype != errs.SubtypeMissingScope { + t.Errorf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) + } + if permErr.Identity != string(core.AsUser) { + t.Errorf("Identity = %q, want %q", permErr.Identity, string(core.AsUser)) + } + wantMissing := map[string]bool{"drive:drive:read": true, "docx:document:read": true} + for _, m := range permErr.MissingScopes { + if !wantMissing[m] { + t.Errorf("unexpected MissingScopes entry %q (granted scopes should not appear)", m) + } + delete(wantMissing, m) + } + if len(wantMissing) != 0 { + t.Errorf("MissingScopes %v did not include expected entries %v", permErr.MissingScopes, wantMissing) + } + if permErr.Hint == "" { + t.Error("Hint must carry the `auth login --scope ...` recovery action") + } + if !strings.Contains(permErr.Hint, "auth login") { + t.Errorf("Hint = %q, want it to mention `auth login`", permErr.Hint) + } +} + func TestCheckShortcutScopes_IgnoresNonContextTokenErrors(t *testing.T) { f := &cmdutil.Factory{ Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}, nil), diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index b0ea305f..1a9210cc 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" @@ -327,21 +328,28 @@ func TestDriveStatusExactRejectsMissingDownloadScope(t *testing.T) { if err == nil { t.Fatal("expected missing_scope error for exact mode without drive:file:download") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected structured exit error, got %T", err) + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T", err) } - if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" { - t.Fatalf("expected missing_scope detail, got %#v", exitErr.Detail) + if permErr.Subtype != errs.SubtypeMissingScope { + t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) } if !strings.Contains(err.Error(), "missing required scope(s): drive:file:download") { t.Fatalf("unexpected error: %v", err) } - if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Hint, "auth login --scope") { - t.Fatalf("missing scope hint not found in detail: %#v", exitErr.Detail) + if !strings.Contains(permErr.Hint, "auth login --scope") { + t.Fatalf("missing scope hint not found: %q", permErr.Hint) } - if !strings.Contains(err.Error(), "drive:file:download") { - t.Fatalf("error should mention drive:file:download: %v", err) + foundScope := false + for _, s := range permErr.MissingScopes { + if s == "drive:file:download" { + foundScope = true + break + } + } + if !foundScope { + t.Fatalf("MissingScopes must include drive:file:download, got %v", permErr.MissingScopes) } } diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index b7446df4..f5513425 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/output" @@ -2364,9 +2365,11 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error { } required := []string{"mail:user_mailbox.message:send"} if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("--confirm-send requires scope: %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant the send permission", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "--confirm-send requires scope: %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` to grant the send permission", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity("user") } return nil } @@ -2387,9 +2390,11 @@ func validateFolderReadScope(runtime *common.RuntimeContext) error { } required := []string{"mail:user_mailbox.folder:read"} if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("folder resolution requires scope: %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant folder read permission", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "folder resolution requires scope: %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` to grant folder read permission", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity("user") } return nil } @@ -2410,9 +2415,11 @@ func validateLabelReadScope(runtime *common.RuntimeContext) error { } required := []string{"mail:user_mailbox.message:modify"} if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("label resolution requires scope: %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant label access permission", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "label resolution requires scope: %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` to grant label access permission", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity("user") } return nil } diff --git a/shortcuts/mail/mail_confirm_send_scope_test.go b/shortcuts/mail/mail_confirm_send_scope_test.go index e93fb215..2ae10aaf 100644 --- a/shortcuts/mail/mail_confirm_send_scope_test.go +++ b/shortcuts/mail/mail_confirm_send_scope_test.go @@ -7,6 +7,7 @@ import ( "errors" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" ) @@ -39,14 +40,14 @@ func assertMissingSendScope(t *testing.T, err error) { if err == nil { t.Fatal("expected error when token lacks send scope with --confirm-send, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError, got %T: %v", err, err) + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAuth { - t.Errorf("expected exit code %d (ExitAuth), got %d", output.ExitAuth, exitErr.Code) + if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth { + t.Errorf("expected exit code %d (ExitAuth), got %d", output.ExitAuth, gotCode) } - if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" { - t.Errorf("expected detail type missing_scope, got %+v", exitErr.Detail) + if permErr.Subtype != errs.SubtypeMissingScope { + t.Errorf("expected subtype %q, got %q", errs.SubtypeMissingScope, permErr.Subtype) } } diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 4b03a7e7..6e98e810 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -25,6 +25,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/credential" @@ -553,9 +554,11 @@ var VCNotes = common.Shortcut{ result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID)) if err == nil && result != nil && result.Scopes != "" { if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "missing required scope(s): %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity(string(runtime.As())) } } return nil diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 4aaa3a3f..76d5acba 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -42,7 +42,7 @@ lark-cli apps +access-scope-set --app-id app_xxx --scope tenant lark-cli auth login --domain apps ``` -命令失败且 `error.type == "missing_scope"` 时,统一引导用户跑: +命令失败且 `error.subtype == "missing_scope"` 时,统一引导用户跑: ```bash lark-cli auth login --domain apps @@ -90,7 +90,7 @@ lark-cli auth login --domain apps - `--path` 既可传单个 HTML 文件也可传目录;目录会**递归打包成 tar.gz 不做过滤**,要提醒用户传干净的产物目录(如 `./dist`),避免把 `.git` / `node_modules` 一起打进去 - `apps +update` 只更新传入字段,未传字段保持不变;`--name` / `--description` 至少传一个,否则 Validate 阶段直接拦截 - `apps +access-scope-set` 三种 scope **互斥**:specific 必传 `--targets`、不允许 `--require-login`;public 必传 `--require-login`、不允许 `--targets` / `--apply-enabled` / `--approver`;tenant 不允许任何其他 flag -- 失败时**优先转述 `error.hint`**(CLI 给的可执行修复建议),hint 为空时退回 `error.message`;不要原样把 envelope JSON 复述给用户。`error.type == "missing_scope"` 例外:按上面「身份与一次性授权」走 +- 失败时**优先转述 `error.hint`**(CLI 给的可执行修复建议),hint 为空时退回 `error.message`;不要原样把 envelope JSON 复述给用户。`error.subtype == "missing_scope"` 例外:按上面「身份与一次性授权」走 ## Shortcuts(推荐优先使用) diff --git a/skills/lark-apps/references/lark-apps-access-scope-get.md b/skills/lark-apps/references/lark-apps-access-scope-get.md index 0a21a09c..c54a4754 100644 --- a/skills/lark-apps/references/lark-apps-access-scope-get.md +++ b/skills/lark-apps/references/lark-apps-access-scope-get.md @@ -51,7 +51,7 @@ lark-cli apps +access-scope-get --app-id app_xxx **失败:** ```json -{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } } +{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } } ``` ## 字段语义 diff --git a/skills/lark-apps/references/lark-apps-access-scope-set.md b/skills/lark-apps/references/lark-apps-access-scope-set.md index 41552f39..6cd7bc67 100644 --- a/skills/lark-apps/references/lark-apps-access-scope-set.md +++ b/skills/lark-apps/references/lark-apps-access-scope-set.md @@ -50,7 +50,7 @@ lark-cli apps +access-scope-set --app-id app_xxx --scope tenant **API 失败:** ```json -{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } } +{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } } ``` **Validate 失败(互斥违反,CLI 本地校验):** diff --git a/skills/lark-apps/references/lark-apps-create.md b/skills/lark-apps/references/lark-apps-create.md index f30d1bca..0c5a6041 100644 --- a/skills/lark-apps/references/lark-apps-create.md +++ b/skills/lark-apps/references/lark-apps-create.md @@ -55,8 +55,8 @@ lark-cli apps +create --name "Demo" --app-type HTML --dry-run { "ok": false, "error": { - "type": "api_error", - "code": "api_error", + "type": "api", + "code": 99991400, "message": "...", "hint": "可执行的修复建议(可能为空)" } diff --git a/skills/lark-apps/references/lark-apps-html-publish.md b/skills/lark-apps/references/lark-apps-html-publish.md index d43d2886..e1d6ae39 100644 --- a/skills/lark-apps/references/lark-apps-html-publish.md +++ b/skills/lark-apps/references/lark-apps-html-publish.md @@ -44,8 +44,8 @@ lark-cli apps +html-publish --app-id app_xxx --path ./dist --dry-run { "ok": false, "error": { - "type": "api_error", - "code": "api_error", + "type": "api", + "code": 90001, "message": "html-publish failed (code=90001): build failed: dependency conflict", "hint": "构建失败:用 `lark-cli apps +html-publish --path --dry-run` 检查打包文件清单" } @@ -57,7 +57,7 @@ lark-cli apps +html-publish --app-id app_xxx --path ./dist --dry-run ```json { "ok": false, - "error": { "type": "infra_error", "message": "...", "hint": "" } + "error": { "type": "network", "message": "...", "hint": "" } } ``` @@ -75,8 +75,8 @@ lark-cli apps +html-publish --app-id app_xxx --path ./dist --dry-run | 字段 / 组合 | 含义 | |---|---| | `data.url` 存在且无 `error` | 发布成功,URL 可访问 | -| `error.type=api_error` | 业务失败(构建失败、应用不存在等),按 `hint` 引导用户修复 | -| `error.type=infra_error` | 网络 / 服务端 5xx,告诉用户稍后重试 | +| `error.type=api` | 业务失败(构建失败、应用不存在等),按 `hint` 引导用户修复 | +| `error.type=network` | 网络 / 服务端 5xx,告诉用户稍后重试 | | `error.type=validation` | 本地参数错,提示用户修 flag | | `error.hint` 非空 | **优先转述给用户**,比 `error.message` 更可操作 | @@ -115,7 +115,7 @@ lark-cli apps +html-publish --app-id "$APP" --path ./dist 转述给用户。 -### 场景 5:网络 / 服务端失败(infra_error) +### 场景 5:网络 / 服务端失败(type=network) > 服务暂时不可用,建议稍后重试。 diff --git a/skills/lark-apps/references/lark-apps-list.md b/skills/lark-apps/references/lark-apps-list.md index 268f59fc..e9fe59c4 100644 --- a/skills/lark-apps/references/lark-apps-list.md +++ b/skills/lark-apps/references/lark-apps-list.md @@ -69,7 +69,7 @@ lark-cli apps +list -q '.data.items[] | select(.name=="客户调研问卷") | .a **失败:** ```json -{ "ok": false, "error": { "type": "api_error", "message": "...", "hint": "..." } } +{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } } ``` ## 字段语义 diff --git a/skills/lark-apps/references/lark-apps-update.md b/skills/lark-apps/references/lark-apps-update.md index 52874655..de320ce4 100644 --- a/skills/lark-apps/references/lark-apps-update.md +++ b/skills/lark-apps/references/lark-apps-update.md @@ -47,7 +47,7 @@ lark-cli apps +update --app-id app_xxx --name "v2" --description "新描述" ```json { "ok": false, - "error": { "type": "api_error", "message": "...", "hint": "..." } + "error": { "type": "api", "message": "...", "hint": "..." } } ``` diff --git a/skills/lark-slides/references/examples.md b/skills/lark-slides/references/examples.md index 5a0c2d0d..b85394b0 100644 --- a/skills/lark-slides/references/examples.md +++ b/skills/lark-slides/references/examples.md @@ -212,7 +212,7 @@ lark-cli slides +replace-slide --as user \ { "ok": false, "error": { - "type": "api_error", + "type": "api", "code": 3350001, "message": "API error: [3350001] invalid param", "hint": "common causes: (1) block_id not found in current slide ..." diff --git a/tests/cli_e2e/config/bind_test.go b/tests/cli_e2e/config/bind_test.go index 28e56837..d66749df 100644 --- a/tests/cli_e2e/config/bind_test.go +++ b/tests/cli_e2e/config/bind_test.go @@ -124,7 +124,10 @@ func TestBind_MissingSource_NonTTY(t *testing.T) { Stdin: []byte{}, // force non-TTY via explicit empty stdin }) require.NoError(t, err) - assertStderrError(t, result, 2, "bind", + // finalizeSource emits a CategoryValidation typed error + // (subtype=invalid_argument, param=--source); this path never goes + // through *core.ConfigError so PromoteConfigError does not apply. + assertStderrError(t, result, 2, "validation", "cannot determine Agent source: no --source flag and no Agent environment detected", "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context") } @@ -181,7 +184,9 @@ func TestBind_Hermes_MissingEnvFile(t *testing.T) { Args: []string{"config", "bind", "--source", "hermes"}, }) require.NoError(t, err) - assertStderrError(t, result, 2, "hermes", + // PromoteConfigError flattens *core.ConfigError{Type:"hermes"} to + // wire error.type="config"; CategoryConfig → exit 3. + assertStderrError(t, result, 3, "config", "failed to read Hermes config: open "+envPath+": no such file or directory", "verify Hermes is installed and configured at "+envPath) } @@ -205,7 +210,9 @@ func TestBind_Hermes_MissingAppID(t *testing.T) { Args: []string{"config", "bind", "--source", "hermes"}, }) require.NoError(t, err) - assertStderrError(t, result, 2, "hermes", + // PromoteConfigError flattens *core.ConfigError{Type:"hermes"} to + // wire error.type="config"; CategoryConfig → exit 3. + assertStderrError(t, result, 3, "config", "FEISHU_APP_ID not found in "+envPath, "run 'hermes setup' to configure Feishu credentials") } @@ -283,13 +290,9 @@ func TestBind_ConfigShow_UnboundWorkspace(t *testing.T) { Args: []string{"config", "show"}, }) require.NoError(t, err) - // Stage-1 wire shape: legacy *output.ExitError envelope (free-string Type - // from ws.Display()). Exit code 3 — config errors share the auth slot per - // ExitCodeForCategory (pre-PR was 2, corrected as part of this PR's - // taxonomy semantics; the per-domain typed migration in stage 2+ will - // land the wire-type rename ("openclaw" → "config") alongside the typed - // envelope shape (subtype, etc.). - assertStderrError(t, result, 3, "openclaw", + // PromoteConfigError flattens *core.ConfigError{Type:"openclaw"} to + // wire error.type="config"; CategoryConfig → exit 3. + assertStderrError(t, result, 3, "config", "openclaw context detected but lark-cli is not bound to it", "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`") } @@ -309,7 +312,9 @@ func TestBind_OpenClaw_MissingFile(t *testing.T) { Args: []string{"config", "bind", "--source", "openclaw"}, }) require.NoError(t, err) - assertStderrError(t, result, 2, "openclaw", + // PromoteConfigError flattens *core.ConfigError{Type:"openclaw"} to + // wire error.type="config"; CategoryConfig → exit 3. + assertStderrError(t, result, 3, "config", "cannot read "+configPath+": open "+configPath+": no such file or directory", "verify OpenClaw is installed and configured") } @@ -409,7 +414,9 @@ func TestBind_LarkChannel_MissingFile(t *testing.T) { Args: []string{"config", "bind", "--source", "lark-channel"}, }) require.NoError(t, err) - assertStderrError(t, result, 2, "lark-channel", + // PromoteConfigError flattens *core.ConfigError{Type:"lark-channel"} to + // wire error.type="config"; CategoryConfig → exit 3. + assertStderrError(t, result, 3, "config", "cannot read "+configPath+": open "+configPath+": no such file or directory", "verify lark-channel-bridge is installed and configured") }