Compare commits

..

121 Commits

Author SHA1 Message Date
xiongyuanwen-byted
d2aa27dac8 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-refactor
# Conflicts:
#	internal/cmdutil/factory_default.go
#	internal/transport/warn_test.go
#	internal/util/proxy.go
2026-06-02 17:53:17 +08:00
zgz2048
e57d97f341 docs: optimize base skill references (#1171) 2026-06-02 17:30:10 +08:00
xiongyuanwen-byted
3bca545796 docs(sheets): sync skill description from spec (cloud-drive alias, lark-drive search, doubao routing) 2026-06-02 17:05:03 +08:00
MaxHuang22
57ba4fae61 feat: unconditionally inject --format flag for all shortcuts (#1156)
* feat: unconditionally inject --format flag for all shortcuts

Removes three HasFormat guards in runner.go so every shortcut
gets --format regardless of the Shortcut.HasFormat field value.
Shortcuts that already define a custom 'format' flag in Flags[]
are skipped to avoid redefinition panics (e.g. mail +triage, +watch).
HasFormat is retained in the struct but marked deprecated.

Change-Id: I5e8fe07e839d5aed4cefaf7d753dabbaee68fb6e

* test: isolate config dir in format-universal test

Change-Id: I3a59942aa8a6753cd949ca42f2a19a72f032ff55

* test: revert unnecessary config-dir isolation (mount-only test)

Change-Id: I0146e5a2f57f5419863bdeeaa1a662fd8f70bddf
2026-06-02 16:55:02 +08:00
YH-1600
925ae5ecd6 docs: add lark drive knowledge organization workflow (#1028)
Change-Id: I2343fcdf26ceefb898cc8d4faeae4b17384cfea8
2026-06-02 16:28:25 +08:00
liangshuo-1
4710a294f5 refactor(transport): own all HTTP transport in internal/transport, fix util layering inversion (#1213)
internal/util imported internal/proxyplugin (SharedTransport, FallbackTransport,
NewHTTPClient, and WarnIfProxied via proxyPluginStatus), so a foundational util
package depended up into a feature package, pulling binding/core/vfs into the
transitive cone of every util importer.

Move internal/proxyplugin -> internal/transport and make it the single owner of
outbound transport: fold the two SharedTransport functions into one Shared()
(proxy-plugin override -> LARK_CLI_NO_PROXY -> http.DefaultTransport), and move
Fallback/NewHTTPClient/WarnIfProxied/DetectProxyEnv/noProxyTransport out of the
now-deleted internal/util/proxy.go into the new package. The proxy-plugin probe
is demoted to a private pluginTransport(); the duplicate redactProxyURL collapses
to one. internal/util keeps no proxy code and is a leaf again.

Re-point all consumers (registry, doctor, config, auth, cmdutil, update) to
internal/transport. Behavior-preserving: package move + symbol rename + dedup.
Two new tests lock the fail-closed contract (plugin overrides NO_PROXY; malformed
config never falls through to direct egress).
2026-06-02 16:10:35 +08:00
xiongyuanwen-byted
a8b29a1cf1 chore: rename ppe x-tt-env lane to ppe_moa_canvas 2026-06-02 15:42:17 +08:00
xiongyuanwen-byted
9c7d5a4b96 Merge origin/main into feat/lark-sheets-refactor
Resolve conflicts:
- secheader.go: keep both HEAD ppe header and main AgentTrace header
- proxy_test.go: union of both test sets; align main's plugin tests to the
  2-arg WarnIfProxied(w, interactive) signature
- SKILL.md: take feat side (refactored 2.0.0 + others' value-render-option removal)
- drop 5 legacy sheets sources + cell-data.md (refactored away; main's
  #996/#1001/#984/#1073 covered by new code or no longer applicable)
2026-06-02 15:36:32 +08:00
zhengzhijiej-tech
ce9764ec2e Merge pull request #1215 from zhengzhijiej-tech/fix/delete-value-render-option
fix(sheets): drop dead --value-render-option flag from +csv-get
2026-06-02 15:00:26 +08:00
zhengzhijie
fc6e60ba5b fix(sheets): drop dead --value-render-option flag from +csv-get
+csv-get wraps get_range_as_csv, which has no value_render_option support
(absent from its input type, executor, and published tool schema — it always
returns formatted display text via getText()). The CLI passed the flag through
as a silent no-op: callers asking for raw_value/formula got formatted values.

Remove the flag from flag-defs, drop the value_render_option passthrough in
csvGetInput, and clean the stale SKILL references. The real value_render_option
capability is unchanged on +cells-get (get_cell_ranges) via --include formula.
2026-06-02 14:57:29 +08:00
xiongyuanwen-byted
5aba007f57 feat(sheets): strip UTF-8 BOM from stdin/@file flag input
resolveInputFlags now strips a leading UTF-8 BOM from content read via stdin
or @file, so it cannot corrupt the first CSV cell or break JSON parsing of
payloads like --operations / --cells downstream.

Also pulls the synced lark-sheets skill docs from sheet-skill-spec and drops
scheme-number tags from two test comments.
2026-06-02 14:26:01 +08:00
JackZhao10086
bc8e9bd6ef feat: increase agent trace max length to 1024 (#1211) 2026-06-02 11:08:53 +08:00
JackZhao10086
f65712cacf feat: add proxy plugin mode for CLI HTTP transport (#1181)
* feat: add security plugin for proxy

* docs: remove outdated proxyplugin README files

* refactor(proxyplugin): tighten proxy URL validation and add security checks

* refactor(proxyplugin): cache blocked transport and clean up error handling

* fix(proxyplugin): fix CR issues for Security hardening

---------

Co-authored-by: AlbertSun <sunxingjian@bytedance.com>
2026-06-02 10:57:02 +08:00
zhangjun-bytedance
915cc623cc feat(vc): inline transcript from artifacts API and add keywords (#1206) 2026-06-02 10:36:41 +08:00
liangshuo-1
3bfb80951d chore(release): v1.0.45 (#1207) 2026-06-01 22:08:11 +08:00
xiongyuanwen-byted
5c22f912aa feat(sheets): close P0-4 pivot gaps — enum case, clear→pivot-delete hint, placement warning
Last open P0 from the 50-trajectory analysis — the two pivot black holes:
upper-cased summarize_by, and pivots built over the source sheet that hit
#REF! and then couldn't be removed.

- enum case tolerance: validateAgainstSchema rewrites a case-only enum
  mismatch to the canonical (lower-case) spelling in place ("SUM" -> "sum")
  before the request is sent, killing the whole class instead of only
  hinting at it. Covers every nested enum (values[], calculated_fields[]);
  genuinely unknown values still fail with the existing did-you-mean message.
- +cells-clear / +cells-batch-clear: when the backend reports "can not find
  embedded block" (the range overlaps a pivot/chart), annotate the error
  with the real fix — clearing cells can't delete an embedded object; remove
  it with +pivot-delete / +chart-delete (id via +pivot-list / +chart-list).
  Applied to both shortcuts, a Tips line, and the cells-clear reference.
- +pivot-create: a --help Tips block making "omit --target-* -> backend
  auto-creates a sub-sheet, zero overwrite" the can't-miss default, plus a
  placement_warning (dry-run + execute output) when an explicit target sheet
  is set with no offset — definite when the target name matches the source
  sheet, conditional otherwise. Local-only, advisory, never blocks the call.

The placement_warning is structured output, not a stderr line, so it
survives non-interactive proxy-warning silencing and isn't swallowed by 2>&1.
2026-06-01 21:47:40 +08:00
xiongyuanwen-byted
7dd479df12 feat(cli): agent-friendly errors, proxy silencing, +csv-put --range
Agent-experience fixes distilled from analyzing 50 real sheets
trajectories, where the top failures were hallucinated command/flag
names, proxy warnings corrupting JSON on stdout, and --range carried
over from +csv-get to +csv-put.

- did-you-mean: unify the duplicated Levenshtein into a shared
  internal/suggest package and wire its prefix-weighted ranker into
  unknown-subcommand and unknown-flag errors; flag-parse errors now
  return a structured envelope with suggestions plus the full valid list,
  so agents recover from semantic typos (e.g. --query vs --find).
- proxy: suppress the one-time proxy warning in non-interactive
  (agent/CI/piped) runs so a 2>&1-merged stderr line cannot corrupt
  stdout JSON; interactive sessions still warn.
- sheets +csv-put: accept --range as an alias for --start-cell (parity
  with +csv-get / +cells-set) and echo the computed writes_range in
  dry-run and the success envelope, so agents see the paste footprint
  before it overwrites neighbours.
- docs(sheets): add an intent->command cheat-sheet to SKILL.md, a
  runtime-prerequisites section, and document the --range alias and
  writes_range behaviour.
2026-06-01 19:27:03 +08:00
hugang-lark
639259fbfd fix: add vc-domain-boundaries and enrich vc +notes (#1172) 2026-06-01 19:03:55 +08:00
zhengzhijiej-tech
46066de29e Merge pull request #1200 from zhengzhijiej-tech/feat/sheets-rows-get
feat(sheets): add --rows-json output flag to +csv-get
2026-06-01 18:35:55 +08:00
zhengzhijie
e58fa13716 feat(sheets): add --rows-json output flag to +csv-get
+csv-get --rows-json returns structured rows ({row_number, values:{col→cell}})
instead of the CSV string, so callers can address cells by row_number / column
letter without parsing [row=N] or RFC-4180 CSV. Same read, alternate output
shape — a flag on +csv-get (default stays CSV), not a separate shortcut, since
the two differ only in representation.

- CsvGet.Execute: --rows-json reshapes the response via assembleRowsJSON
  (parses annotated_csv into per-row records keyed by column letter; every
  logical row emitted; embedded newlines parsed into cell values)
- surfaces the under-read hint structurally as data_not_fully_read
- flag-defs.json + read-data reference synced from spec
2026-06-01 17:04:22 +08:00
JackZhao10086
0bdd7de807 refactor(auth): update login hint and split-flow docs (#1201) 2026-06-01 16:47:18 +08:00
evandance
99e314fe0b 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.
2026-05-30 19:08:41 +08:00
sang-neo03
50b3f0a2af feat(platform): support multiple policy rules per plugin (#1182)
* feat(platform): support multiple policy rules per plugin

Extend the command policy framework from single-Rule to multi-Rule
semantics. A plugin (or policy.yml) may now contribute several scoped
Rules; the engine combines them with OR -- a command is allowed when it
satisfies every axis of at least one rule. This lets one integration
apply different risk ceilings and identity restrictions to different
command groups.

The cross-plugin fail-closed boundary is preserved: two distinct plugins
both calling Restrict still aborts startup (multiple_restrict_plugins).
Single-Rule behaviour is fully backward compatible -- the rejection
reason_code / rule_name / envelope shape are byte-for-byte unchanged;
multi-rule rejection surfaces the aggregate reason_code no_matching_rule.

- engine: New keeps single-rule compat, add NewSet for OR over rules
- resolver: dedupe by owner (one plugin may contribute many rules),
  return []*Rule; yaml gains a top-level rules: list
- registrar/builder/staging: Restrict may be called more than once;
  retire the double_restrict error
- config policy show / config plugins show: emit a rules array
- inventory: PluginEntry.Rules is now a slice (fixes last-rule-wins
  overwrite when a plugin contributes multiple rules)

* fix(platform): clone rules in Builder.Restrict and inventory snapshot

Address review feedback. Builder.Restrict stored the caller's *Rule
directly, so reusing and mutating one Rule object across multiple
Restrict calls collapsed entries to the last mutation; clone the rule and
its slices on append, mirroring the staging registrar.

BuildInventory likewise reused the source Allow/Deny/Identities slices;
copy them when building the RuleView snapshot instead of relying on
cloneInventory downstream.

Add a regression test: reusing and mutating one Rule across two Restrict
calls now yields two independent rules.

* fix(platform): skip yaml when a plugin owns policy; reject empty rules list

Two policy-config robustness fixes from review:

- A malformed ~/.lark-cli/policy.yml could abort a plugin-governed
  binary. applyUserPolicyPruning read yaml before resolving, and
  build.go fail-closes on any policy error when a plugin is present.
  Plugin rules shadow yaml anyway, so skip reading yaml entirely when a
  plugin contributed rules -- an unrelated broken file on the user's
  machine can no longer lock the CLI.

- A present-but-empty "rules: []" collapsed to a single all-zero Rule
  that allows every annotated command ("looks like policy, enforces
  almost nothing"). yaml.Parse now distinguishes absent from
  present-but-empty (Rules is a pointer) and rejects the empty list.

Add regression tests for both.
2026-05-30 17:05:33 +08:00
syh-cpdsss
b1ecf2d0f9 fix: whiteboard skill (#1180)
Change-Id: If62f9446dea1273a422567394a9e7d91b40be16e
2026-05-30 10:35:01 +08:00
zhengzhijiej-tech
f99b5bf32e Merge pull request #1160 from zhengzhijiej-tech/fix/eval-issues
docs(sheets): three SKILL-entry refinements derived from eval traces
2026-05-29 14:07:35 +08:00
xiongyuanwen-byted
74761a0e1c feat(sheets): add schema-driven JSON flag validation
Validate composite JSON flags (--properties, --cells, --options,
--border-styles, --sort-keys) against the embedded flag-schemas.json
on every standalone and +batch-update sub-op invocation, replacing
ad-hoc per-shortcut guards.

Supports the JSON Schema subset actually used upstream: type / enum
/ oneOf / required / properties / items / nullable / minimum /
maximum / minItems / maxItems / additionalProperties (true | false
| <schema>). Enum errors quote the failing value, truncate beyond 8
entries, and surface case-only "did you mean" hints (SUM -> sum).

Coverage: 18 / 19 (shortcut, flag) pairs. +batch-update --operations
stays validator-skipped; its translator already does richer per
sub-op checks. mapFlagView.Command() routes batch sub-ops through
the same (command, flag) -> schema pipeline as standalone.

loadFlagSchemas() is now sync.Once-guarded so parallel first access
from t.Parallel test sets and concurrent shortcut invocations is
race-free.

Removes superseded hand-written guards:
  - +pivot-create validateCreateInput / validatePivotCreateProps
  - +range-sort sort-keys per-item shape check
Test fixtures updated to be schema-conformant (chart position/size,
pivot summarize_by lowercase, cells 2D-array shape).
2026-05-29 12:46:23 +08:00
zhengzhijie
7d24e2b649 docs(sheets): tell agent to set +H for A1 references containing !
Synced from spec. The sheet-locator section now warns: when a flag value
contains `!` (--source / --range / --ranges with a cross-sheet prefix),
run `set +H` at the start of the bash session to disable history
expansion — otherwise interactive bash (e.g. inside an agent's shell
sandbox) lexes "Sheet1!A1" as a history reference and fails with
`event not found` before lark-cli ever sees the argument.

When the sheet name itself contains hyphens / spaces / non-ASCII
characters, the A1 reference also needs single quotes around the sheet
name per A1 notation, e.g. --source "'Sales-2025'!A1:D100".

Also flips the previous `--range` example to `--range 'Sheet1!A1:B2'`
(shell single-quote) for consistency.
2026-05-29 11:43:00 +08:00
zhengzhijie
e3eca666fb docs(sheets): require +workbook-info before guessing sheet name
Synced from spec. SKILL.md adds a new rule under the sheet-locator
section: unless the user has explicitly named a sheet, the agent must
call +workbook-info first to fetch sheets[].sheet_id / sheets[].title
rather than guessing the default `Sheet1`. The Chinese-language tables
this CLI is typically used against rarely use that literal name —
"数据" / "Sheet" (no digit) / "工作表 1" / business-named sheets are
far more common — so guessing wastes a round-trip before the agent
ends up calling +workbook-info anyway.

The 统一调用范式 example also switches its `--sheet-name "Sheet1"`
placeholder to `<真实表名>` to remove the inadvertent suggestion that
`Sheet1` is a sensible default.
2026-05-28 20:42:45 +08:00
zhengzhijiej-tech
da65e37647 Merge pull request #1152 from zhengzhijiej-tech/fix/eval-issues
docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec
2026-05-28 20:36:42 +08:00
zhengzhijie
dce617eab2 docs(sheets): strip migration-history language from pivot reference / SKILL
Synced from spec. Removes "renamed from / no longer called / not
--sheet-id" style migration-history language that snuck into the
previous sync. Reference and SKILL now describe the current flag names
directly without referencing the old names.
2026-05-28 19:23:42 +08:00
zhengzhijie
9c3d30aa00 feat(sheets): rename +pivot-create sheet selector → --target-sheet-{id,name}
+pivot-create's placement selector (where the pivot table lands) is no
longer the generic --sheet-id / --sheet-name; it is now
--target-sheet-id / --target-sheet-name. The new names mark this as the
*output* sheet, distinct from the *data-source* sheet (which lives
inside --source as `'Sheet'!Range`). The other +pivot-{list,update,delete}
shortcuts keep --sheet-id / --sheet-name (their semantics are
"sheet that hosts the existing pivot", same as every other shortcut).

Motivation: an LLM agent reading the previous CLI surface saw +pivot-create
expose --sheet-id and assumed (as it had to) that it pointed at the data
source, like every other shortcut. The new flag name makes the intent
unambiguous at the call site, without relying on the agent having read
the narrative caveat in the reference doc.

Background: evaluation case U046 spent multiple rounds tripping on this
exact confusion before working around it with +sheet-rename.

Implementation:
- objectCRUDSpec gains createSheetIDFlag / createSheetNameFlag (with
  default-fallback accessors sheetIDFlagOnCreate / sheetNameFlagOnCreate);
  newObjectCreateShortcut + objectCreateInput consult the spec instead of
  hard-coded "sheet-id" / "sheet-name". pivotSpec sets target-sheet-*;
  every other create spec inherits the defaults.
- optionalSheetSelector (only used by pivot create) takes the two flag
  names as parameters so its mutex / control-char errors quote the names
  the user actually typed (--target-sheet-id, not --sheet-id).
- batch_op_dispatch: introduce sheetSelectorFlagsForSubOp(shortcut) →
  (idFlag, nameFlag) returning target-sheet-* for "+pivot-create" and
  the defaults otherwise; translateBatchOp uses it so +pivot-create
  sub-ops in +batch-update accept the same renamed input keys.
- Tests:
  - lark_sheet_object_crud_test.go: pivot-create cases switch args and
    expected error wording to target-sheet-*; extra assertion that the
    mutex error quotes the renamed flag (regression guard against
    flag-name drift between code and error message).
  - batch_op_contract_test.go: +pivot-create sub-op test uses
    target-sheet-id / target-sheet-name input keys; the body-vs-standalone
    contract loop reads the selector via sheetSelectorFlagsForSubOp so
    every other shortcut keeps using sheet-id / sheet-name.

Synced reference docs (skills/lark-sheets/{SKILL.md,
references/lark-sheets-pivot-table.md}) mirror the spec's new flag names,
narrative, 3-placement-strategy block, and SKILL.md exception bullet that
explains why +pivot-create's badge says 无 sheet 定位 yet still has
placement selectors (just under different names).

flag-defs.json synced from spec picks up the renamed flags + kind=own.

All sheets-package tests pass.
2026-05-28 19:15:38 +08:00
zhengzhijie
690e746896 docs(sheets): drop bash histexpand section, fix write-cells table escape
Sync from spec, refining the bash-quoting deep-dive added in 0f695b6:

- Drop the `## Shell 调用注意事项` section in SKILL.md and the inline
  `⚠️ bash 引号` callouts in lark-sheets-pivot-table.md and
  lark-sheets-write-cells.md. The 4-scenario quoting table + anti-pattern
  list turned out too verbose for the SKILL intro; single-quoted examples
  in the references are themselves enough nudge.
- lark-sheets-write-cells.md L146: fix the table cell escape from the
  malformed `'''Sheet1''!T1:T3'` (consecutive `''` are no-op empty
  strings) to `''\''Sheet1'\''!T1:T3'`, matching the bash example at
  L191 verbatim.

Net: 1 insertion, 40 deletions across 3 files.
2026-05-28 18:04:20 +08:00
zhengzhijie
0f695b60ec docs(sheets): wrap A1 sheet names in handwritten examples + bash histexpand guide
Synced from spec. Affects 4 reference md (chart / pivot-table / sparkline /
write-cells) and SKILL.md.

In addition to wrapping sheet names in single quotes in all remaining
handwritten examples (covers chart refs.value / nameRef, sparkline source,
write-cells --source-range, pivot-create narrative), SKILL.md gains a new
"Shell quoting for A1 references with !" section.

The new section addresses bash history expansion: in interactive bash
(e.g., ShellExec sandbox), unescaped `!Word` after `"..."` triggers
`bash: !A1: event not found`, dropping the command before lark-cli sees
it. The section gives 4 quoting strategies (shell single-quote outer,
`set +H` prefix, mixed quoting, sheet-rename fallback) and an anti-pattern
list.

Affected files:
- skills/lark-sheets/SKILL.md (new section)
- skills/lark-sheets/references/lark-sheets-chart.md
- skills/lark-sheets/references/lark-sheets-pivot-table.md
- skills/lark-sheets/references/lark-sheets-sparkline.md
- skills/lark-sheets/references/lark-sheets-write-cells.md
2026-05-28 17:51:00 +08:00
zhengzhijie
77f86ec2fd docs(sheets): wrap sheet names in single quotes in A1 examples
Synced from spec. Affects 3 reference md (pivot-table / batch-update /
write-cells) and 2 generated flag-data JSONs.

A1 examples like `Sheet1!A1:D100` now read `'Sheet1'!A1:D100` so models
default to single-quoted sheet names. Excel A1 notation requires single
quotes for sheet names containing hyphens / spaces / non-ASCII chars;
always-quoting is also valid for plain names, so this is the safer default
to teach.

Affected flags:
- +pivot-create --source
- +dropdown-update --ranges / --source-range
- +dropdown-delete --ranges
- +dropdown-set --source-range
- +cells-batch-set-style --ranges
- +cells-batch-clear --ranges
2026-05-28 17:38:15 +08:00
zhengzhijie
8e84f47d3e docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec 2026-05-28 16:34:34 +08:00
zhengzhijiej-tech
5ebb1398e7 Merge pull request #1138 from zhengzhijiej-tech/feat/dim-resize-a1-range-schema
refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema
2026-05-27 19:59:24 +08:00
zhengzhijie
0476dec83c docs(sheets): sync +pivot-create placement reference from spec
Companion sync from sheet-skill-spec — the canonical reference rewrites
+pivot-create's "5 placement-related flags" rundown into a clearer
"4 placement-related flags" form (--target-sheet-id was already removed
in #1130, this updates the prose accordingly), and clarifies that
--sheet-id / --sheet-name on +pivot-create are the *placement* sheet
(not the source-data sheet), with omit-both as the strongly-recommended
default.

Also picks up a base-side --target-position description tweak that
dropped the now-stale "与 --target-sheet-id 配套" reference.

No CLI surface change.
2026-05-27 19:46:36 +08:00
zhengzhijie
69d2851163 refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema
The 9 row/column-region shortcuts used to share two int flags --start /
--end with inconsistent end semantics across commands — +dim-insert /
-delete / -hide / -unhide / -group / -ungroup treated --end as exclusive,
while +dim-move / +rows-resize / +cols-resize treated it as inclusive.
The skill reference even called this out as "the highest-frequency
off-by-one source", patched in docs rather than at the surface. Three
underlying tool schemas (position+count, A1 range string, 0-based int
pair) were all flattened onto the same --start/--end pair, which forced
a different normaliser per command and pushed mental math (count =
end - start) onto every caller.

Schema (sourced from base, regenerated via sheet-skill-spec, mirrored
into shortcuts/sheets/data/ and skills/lark-sheets/):

  +dim-insert                                    --position + --count
    rows: "3"; columns: "C". --count rows/columns
    inserted *before* --position.
  +dim-delete / -hide / -unhide / -group / -ungroup
                                                 --range
  +rows-resize / +cols-resize                    --range
    A1 closed range. Rows: "3:7" or "5". Columns: "C:F" or "C".
    Mixing letters and digits in one range is rejected.
  +dim-move                                      --source-range + --target
    --target must match --source-range's dimension (both row or both
    column). The move places the source block *before* --target.

Wire-shape preserved: modify_sheet_structure still receives `position`
+ `count` (insert) or a `range` A1 string (other dim-* ops); v3
move_dimension still receives 0-based inclusive ints (CLI parses the
A1 strings into them); resize_range still receives a two-sided A1
range (single-element form is expanded to "N:N" before send).

This is a flag-surface break (--start / --end / --dimension flags
removed from these 9 shortcuts); --dimension stays only on +dim-freeze
since it has no range to derive from.

Code: A1 parser added (parseA1Range / parseA1Position /
letterToColumnIndex reused from write_cells); dimRange / dimRangeFull /
dimPosition deleted; dim-move switches to source-range + target parsing;
resize gains a same-dimension guard so +rows-resize rejects "A:C" with
a clear "+rows-resize expects row numbers" message.

Tests: TestSheetStructureShortcuts_DryRun / TestDimMove_DryRun /
TestDimMove_Column / TestDimMove_MismatchedDimension /
TestDimRange_Validation / TestParseA1Range / TestResize_TypeAndSizeGuards
/ TestRangeOperationsShortcuts_DryRun all rewritten against the new
schema. Batch contract trio (BodyMatchesStandalone /
ErrorEquivalence / RejectsBadSubOpInput) and
TestBatchOp_DispatchCoversReportedBugs likewise. Full
`go test ./shortcuts/sheets/` passes.
2026-05-27 19:36:07 +08:00
xiongyuanwen-byted
ce5878e3c4 docs(sheets): clarify filter/filter-view rules update is whole-set PUT
Synced from upstream tools-schema. The rules field on manage_filter_object and manage_filter_view_object now documents update as whole-set PUT semantics: submitted rules become the complete rule set, all existing columns' rules are cleared first, columns not listed lose their old rules (no merge), and [] clears everything. Description-only change, no structural/field change.
2026-05-27 19:16:04 +08:00
zhengzhijiej-tech
e85afd68d2 Merge pull request #1130 from zhengzhijiej-tech/fix/pivot-create-schema-alignment
fix(sheets): allow +pivot-create to omit both sheet selectors
2026-05-27 17:01:46 +08:00
zhengzhijie
a09593a0fe fix(sheets): allow +pivot-create to omit both sheet selectors
manage_pivot_table_object treats sheet_id / sheet_name as the placement
target — when both are absent, handleCreate() auto-creates a new sub-sheet
to host the pivot table. The CLI's flag schema didn't reflect this:

- Exposed a third flag --target-sheet-id that mapped to the same wire
  field as --sheet-id, leaving the caller unsure which one to use
- --sheet-id / --sheet-name had "XOR with the other" descriptions that
  read like "operation context", so callers (especially LLM tool callers)
  felt obligated to set one — frequently the source sheet — which
  silently disabled the backend's auto-create guardrail and dropped the
  pivot at A1, overlapping the source data

Wire change (synced from sheet-skill-spec): drop the duplicate
--target-sheet-id flag; rewrite --sheet-id / --sheet-name descriptions
to make the placement-target semantics explicit and call out that
omitting both is the recommended path.

Implementation change (this PR): add an at-most-one sheet-selector
helper and let object create-shortcuts opt into it.

- helpers.go: new optionalSheetSelector (both empty allowed; both set
  still rejected; control-char validation unchanged). requireSheetSelector
  is untouched — every existing caller keeps the exactly-one contract.
- lark_sheet_object_crud.go: objectCRUDSpec gains
  allowEmptySheetSelectorOnCreate; objectCreateInput dispatches to
  optionalSheetSelector when it's set. Only pivotSpec opts in;
  chart / cond-format / sparkline / filter-view / float-image keep
  the existing require semantics. DryRun and Execute switch to direct
  flag extraction (same pattern Validate already used) so the XOR
  check happens in exactly one place (the builder).
- pivotSpec: drop the enhanceCreateInput branch that read the now-removed
  --target-sheet-id flag.
- Tests: TestPivotCreate_SheetSelectorSemantics covers both-empty /
  both-set / single-set; TestObjectCreate_RequiresSheetSelector
  regresses chart / cond-format / sparkline / filter-view to lock the
  scope of the relaxation.
2026-05-27 16:11:07 +08:00
xiongyuanwen-byted
60c61d8157 docs(sheets): sync lark-sheets skill from spec
Mirror the canonical-spec reference fixes into the consumer skill:
- search_replace output contract: `matches[]` with `address` (+ `has_more`/`next_offset`)
- workbook sheet fields: `sheet_name`/`is_hidden`/`*_count`, no `frozen_*`
- `+range-fill` example uses a non-overlapping target (A3:A100)
- drop the unimplemented `envelope.meta.verification` auto-readback claim; advise
  manual list/get verification instead
2026-05-27 11:56:42 +08:00
xiongyuanwen-byted
08e8b5c870 fix(sheets): allow +float-image-update to omit the image source
The image source (--image-token / --image-uri) is the only optional part
of an update: omit all of them to keep the current image. image_name,
position and size stay required — the manage_float_image tool rejects an
update without them, and +float-image-list does not return image_name to
backfill. Previously the shortcut forced an image source even when only
position/size changed, so those updates were rejected CLI-side before any
API call (reported as a Fail case in the sheets e2e rerun).

- floatImageProperties: gate the image-source requirement on create only;
  keep image_name/position/size required on both; emit image_uri only when set
- sync flag-defs.json + lark-sheets-float-image.md from sheet-skill-spec
  (image-name/position/size now required on +float-image-update)
- tests: cover the image-source-optional dry-run; the single-required checks
  move to the +batch-update sub-op path (cobra owns the standalone path)
2026-05-27 11:10:32 +08:00
zhengzhijiej-tech
338cdaa6db Merge pull request #1107 from zhengzhijiej-tech/sheets/float-image-reference-examples
docs(sheets): fix non-runnable float-image reference examples
2026-05-26 21:36:15 +08:00
zhengzhijie
55ccbc5f6a feat: 同步 tools-schema.json 改动 2026-05-26 21:34:04 +08:00
zhengzhijie
bea4c746ae docs(sheets): sync float-image reference from spec — fix non-runnable examples
Two examples in skills/lark-sheets/references/lark-sheets-float-image.md
didn't actually run against PPE; sync brings them in line with CLI behavior:

- +float-image-create local-path example missed --image-name (CLI rejects
  with `required flag(s) "image-name" not set` even when path basename
  already has the filename). Add `--image-name "logo.png"` + inline note.
- +float-image-update "only change position" example missed image source
  (CLI rejects with `one of --image, --image-token, or --image-uri is
  required`). Expand to two steps: list with --jq pulls the current
  image_token, then update re-passes --image-token to satisfy the guard.
- Leading warning realigned: image source is mandatory on every update
  call; "keep original image" still requires passing the token explicitly.

Upstream change: sheet-skill-spec MR fix/float-image-reference-examples.
2026-05-26 20:31:18 +08:00
zhengzhijiej-tech
4b16fe9ce0 Merge pull request #1098 from zhengzhijiej-tech/fix/batch-ranges-prefix-clarity
fix(sheets): +dropdown source-range warns when highlight + cells > 2000
2026-05-26 16:58:55 +08:00
zhengzhijie
f53e55ce65 fix(sheets): align +cells-search/+cells-replace option keys with server schema
The CLI emitted `options.regex` and `options.include_formulas`, but the
server-side `search_data` / `replace_data` tool schemas declare and
consume `use_regex` and `match_formulas`. Result: passing `--regex` or
`--include-formulas` always failed with `unexpected property ... is not
defined in schema`.

Keep the user-facing flag names (`--regex`, `--include-formulas`) — only
the JSON keys sent to the server change. Updates the dry-run test that
locked the wrong contract.
2026-05-26 16:13:53 +08:00
zhengzhijie
71eae77f65 docs(sheets): sync lark-sheets skill from spec — +filter-view-update --properties desc
去掉 +filter-view-update --properties 描述里"pass at least one of
--properties.rules / --range / --view-name"的误导承诺。--properties
实际是硬必填(MarkFlagRequired),且 update 走 PUT 整组覆盖语义。
2026-05-26 16:08:14 +08:00
zhengzhijie
08d025945e revert(sheets): drop tools-schema drift mirror from previous spec sync
930c9c7 顺带 sync 了 spec 的 tools-schema bundling — 跟那条 commit 一起
误带进来 chart float block required 和 data_validation.items 描述微调,
这两处其实是上游 sheet-ai-skills 还在 pending 的 revert。

配套 sheet-skill-spec 的 revert commit (a3aa9f2 on
fix/dropdown-flag-desc-real-limits / !11),重跑 sync:consumers 拉回
正确的 generated mirror:
- shortcuts/sheets/data/flag-schemas.json(chart 部分)
- skills/lark-sheets/references/lark-sheets-{chart,batch-update,write-cells}.md(rendered schema 段)

dropdown 文案改动(flag-defs.json 4 处 desc + dropdown 段的 reference
渲染)不在本 commit 范围,保持 930c9c7 的状态。
2026-05-26 15:31:40 +08:00
zhengzhijie
930c9c77a8 docs(sheets): sync lark-sheets skill from spec — dropdown flag descs reflect server reality
Pulls sheet-skill-spec canonical-spec → generated → consumers chain for
dropdown flag desc corrections committed upstream (Shortcut-flags base
table rows for +dropdown-set / +dropdown-update --options and
--source-range).

Aligns flag descs with byted-sheet behavior:
- --options: dropped fabricated "≤500 items, each ≤100 chars, no commas"
  promise. byted-sheet ListOfItemValidation enforces none of these.
- --source-range: appended note about the only real cap —
  LIST_WITH_COLOR_MAX_COUNT=2000 when --highlight is on (server flags the
  dropdown as option-error beyond that; CLI warns at Validate time per
  bb7ccae).

Also picks up an unrelated upstream tools-schema.json drift (chart float
block schema + data_validation.items description tweak) that surfaced
via npm run check:tool-schemas; bundling keeps the spec sync gate green.
2026-05-26 15:16:27 +08:00
zhengzhijie
bb7ccaedf9 fix(sheets): warn when +dropdown source-range exceeds 2000 cells with highlight on
byted-sheet's ListFromRangeValidation.checkOptionsValid() sets
isOptionError=true when shouldHighlightValidData is on and the source
range exceeds LIST_WITH_COLOR_MAX_COUNT (2000 cells) — the highlight +
large source combo is unsupported. CLI previously had no signal for
this, so users only learned by seeing the dropdown render as
option-error in the workbook.

Add a Validate-phase stderr warning in +dropdown-set and +dropdown-update
when --source-range covers >2000 cells unless --highlight=false. Soft
warning, never blocks the request. Inline --options is not subject to
this limit — server enforces no count or per-item length cap on inline
lists, so no warning fires there.
2026-05-26 14:52:58 +08:00
zhengzhijie
a0d6472e9f feat: 同步 tools-schema.json 改动 2026-05-26 14:48:12 +08:00
xiongyuanwen-byted
6b3c0b5556 docs(sheets): sync lark-sheets skill docs from upstream spec
- SKILL.md: clarify --url only resolves /sheets/ and /spreadsheets/ links; /wiki/ links must be resolved via wiki +node-get first (confirm obj_type=sheet, use obj_token)
- formula-translation: document IMPORTRANGE cross-workbook limits (max 5-level nesting, 100 refs per sheet)
- write-cells: document rich_text cells for hyperlinks, @mentions and @docs
2026-05-26 12:50:52 +08:00
zhengzhijiej-tech
f4bcb85d2e Merge pull request #1089 from zhengzhijiej-tech/fix/batch-ranges-prefix-clarity
fix(sheets): clarify batch --ranges prefix must be sheet display name
2026-05-26 11:02:11 +08:00
zhengzhijie
5880d070e2 fix(sheets): clarify batch --ranges prefix must be sheet display name
E2E test cases repeatedly trip on this:

  $ lark-cli sheets +cells-batch-set-style \
      --ranges '["7f8fba!A2:B3","7f8fba!C2:D3"]' --font-color '#3366FF' ...

  → tool "batch_update" failed: [900015206]
    sheet "7f8fba" not found. Available sheets: [{id: "7f8fba", name: "Sheet1"}]

Callers paste the hex sheet-id (e.g. "7f8fba") from a spreadsheet URL /
+sheet-create response straight into the --ranges sheet prefix. The four
batch shortcuts (+cells-batch-set-style / +cells-batch-clear /
+dropdown-update / +dropdown-delete) fan each range out into a
batch_update sub-op (set_cell_range / clear_cell_range) and pass the
prefix through as sheet_name; the server only matches sheet_name
literally, so the lookup fails.

The set_cell_range tool schema is explicit: sheet_id is the
reference_id and "must be correct or it errors"; sheet_name is the
display name. CLI can't disambiguate purely from the literal because
users can rename sheets to anything (including six-char hex strings).

Cleanest fix is at the source: each batch shortcut's --ranges flag
description now states explicitly that the prefix must be the sheet
display name and that the sheet reference_id is rejected, so agents
reading the reference don't try the id form in the first place.

No Go changes; these files are regenerated from the upstream Lark Base
Shortcut-flags table via the sheet-skill-spec sync chain.
2026-05-25 22:17:33 +08:00
zhengzhijiej-tech
2082095f18 Merge pull request #1090 from zhengzhijiej-tech/fix/dropdown-get-sheet-id-prefix
feat(cmdutil): route requests via PPE test lane via secheader
2026-05-25 22:11:25 +08:00
zhengzhijie
6cadbe807a feat: 把环境变量提交上去 2026-05-25 21:56:25 +08:00
zhengzhijiej-tech
927a73faa2 Merge pull request #1083 from zhengzhijiej-tech/fix/dropdown-get-sheet-id-prefix
fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range
2026-05-25 21:28:30 +08:00
zhengzhijie
9c447e735b fix(sheets): drop Sheet1! prefix from +cells-get / +csv-get / +csv-put flag examples
Server tools-schema.json for get_cell_ranges, get_range_as_csv and set_range_from_csv
does not accept a sheet prefix on --range / --start-cell; the sheet is selected via
--sheet-id / --sheet-name. +csv-put --start-cell also now states it must be a single
cell (no range notation).

Synced from spec repo.
2026-05-25 21:24:30 +08:00
zhengzhijie
12b94746bb fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range
Align +dropdown-get with its get_cell_ranges siblings (+cells-get / +csv-get):
sheet selection is now via --sheet-id / --sheet-name (XOR) and --range is a
bare A1 reference. The previous shape required the sheet prefix inside --range
(e.g. "Sheet1!A2:A100") and was the odd one out among the read-data wrappers;
callers pasting the sheet-id form straight from the URL hit a misleading
"sheet not found, sheetId: , sheetName: <id>" error because the prefix was
unconditionally treated as sheet_name.

Flag schema + skill reference regenerated from the upstream Lark Base
Shortcut-flags table.
2026-05-25 21:08:58 +08:00
xiongyuanwen-byted
5327e9390d feat(sheets): support local --image in +float-image-create
+float-image-create now accepts a local file via --image (XOR with
--image-token / --image-uri): the CLI uploads it as a sheet_image and
embeds the returned file_token, removing the previous "upload elsewhere
to get a token first" workaround. Path safety is checked in Validate,
--dry-run previews the extra upload step, and +batch-update rejects
--image (no upload phase). +float-image-update is unchanged (it does not
register --image).

Also syncs the lark-sheets skill docs/flag-defs from sheet-skill-spec:
the new --image flag, partial-merge / border-per-side / bare sheet-prefix
clarifications, and refreshed dropdown --colors/--highlight descriptions
(already pending in the source Base table).
2026-05-25 19:20:50 +08:00
xiongyuanwen-byted
dece428487 fix(sheets): validate +cells-set-image --image path in Validate
The unsafe-path check only ran at Execute (via FileIO.Stat), so --dry-run
printed a misleading success preview for an absolute / out-of-cwd --image
path that a real run would then reject. Move the path-safety check into
Validate (validate.SafeLocalFlagPath), so --dry-run and Execute fail
identically and both name the real --image flag. File existence stays
deferred to Execute, so legitimate relative paths still preview cleanly.

Add TestCellsSetImage_DryRunRejectsUnsafePath.
2026-05-25 19:20:33 +08:00
zhengzhijiej-tech
ff493d4534 Merge pull request #1075 from zhengzhijiej-tech/fix/sheets-e2e-fixes-batch1
fix(sheets): align +dropdown / +workbook-create / +range-sort with server + tri-state --highlight
2026-05-25 19:13:24 +08:00
zhengzhijie
ff78ff40d8 chore(sheets): sync +dropdown flag desc + write-cells narrative from spec
Mirror sheet-skill-spec generated/ into shortcuts/sheets/data/ and
skills/lark-sheets/ for the +dropdown-set / +dropdown-update path. No
hand edits in this repo.

The +dropdown flag desc and the Dropdown 配色 narrative now match the
server-side enable_highlight default flip (true) and the tri-state
--highlight semantics introduced in the sibling commit:

  * --highlight desc: 不传 = 开(按内置 10 色色板循环上色),
    --highlight=false 关闭得到纯白下拉
  * --colors desc: 单独传即生效(高亮默认开),--highlight=false 时忽略
  * write-cells reference: 三种意图三条线(默认色板 / 指定颜色 /
    纯白下拉)+ 新增 --highlight=false 示例

Source upstream: sheet-skill-spec MR !8.
2026-05-25 17:48:06 +08:00
zhengzhijie
1a2d2d04be feat(sheets): +dropdown --highlight tri-state via Changed() for opt-out
The server-side default for data_validation.enable_highlight flipped from
false to true (aligning with the UI behavior). With the previous code path

    if runtime.Bool("highlight") { dv["enable_highlight"] = true }

omitting --highlight and passing --highlight=false both produced the same
"enable_highlight key absent" body, leaving CLI users with no way to opt
out of the (now-default) highlighting.

Switch to runtime.Changed() so the translator can distinguish all three
input shapes:

  - omitted          -> no enable_highlight key (server applies default=true)
  - --highlight=true -> enable_highlight: true  (explicit no-op vs default)
  - --highlight=false -> enable_highlight: false (the only opt-out path)

flagView already exposes Changed() and mapFlagView (the +batch-update
sub-op adapter) implements it via raw-key presence — same pattern other
translators use for "Changed-only" branching (e.g. omit target_index
unless --index was set), so no interface surface change is needed.

Test coverage:
  - TestDropdownSet_HighlightTriState pins all four shapes (omit / presence
    form / explicit true / explicit false) and asserts the enable_highlight
    key's presence/value
  - TestBatchOp_BodyMatchesStandalone adds a --highlight=false sub-op case
    so the batch sub-op path produces a body byte-identical to the
    standalone +dropdown-set --highlight=false body
2026-05-25 17:40:17 +08:00
zhengzhijiej-tech
5eaa70b74a Merge pull request #1030 from zhengzhijiej-tech/fix/sheets-e2e-fixes-batch1
fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema (restore --colors/--highlight)
2026-05-25 14:43:15 +08:00
zhengzhijie
f0dea38aeb fix(sheets): post-rebase test fixups after dropping superseded fix #1
Two test fallouts from rebasing onto upstream 4be06c8 (which independently
re-fixed +workbook-create and +dim-move with a more thorough approach):

- shortcuts/sheets/lark_sheet_workbook_test.go: our PR's earlier
  TestWorkbookCreate_DryRun "with headers and data → 2-step plan" subtest
  asserted the expedient sheet_name="Sheet1" / no-sheet_id wire body that
  matched our dropped fix #1 implementation. Upstream's fix #1 resolves
  the workbook's first sheet via get_workbook_structure and fills with
  the real sheet_id instead. Reset this file to upstream's version — our
  superseded assertions disappear, upstream's tests cover the new wire
  shape.

- shortcuts/sheets/execute_paths_test.go: TestExecute_RangeSort fixture
  still used the legacy {col, order} sort-key shape because the rebase
  resolution picked the upstream version of this file wholesale (it
  contained other unrelated changes). Re-apply just the one fixture
  update to {column, ascending} so fix #5's CLI-side rejection logic
  exercises a valid input — server-side sort_conditions has required
  fields `column` (string) and `ascending` (bool); the historical
  {col, order} vocabulary was never accepted.

go build ./... + go test ./shortcuts/sheets/... -count=1 both green.
2026-05-25 14:41:59 +08:00
zhengzhijie
fa503fa47f chore(sheets): restore --colors in parseJSONFlag docstring example list
The earlier commit 49104ec swapped --colors out of parseJSONFlag's "Used
by" example list when it deleted the flag (item #2 there removed --colors
/ --highlight from +dropdown-set/-update). Subsequent commits 8672d8e /
538eb2e / fb90c8b reinstated --colors (and added --source-range) but did
not roll back this docstring tweak — leaving an orphan reference to
--properties where --colors used to be.

This restores the example list to its pre-49104ec form so the docstring
matches what the helper actually services on this branch's HEAD.

Pure docstring change — function behavior unaffected, no test movement.
2026-05-25 14:39:36 +08:00
zhengzhijie
38ef6ad51e docs(sheets): sync agent-voice rewrite of Dropdown 选项+配色 from spec
Mirrors sheet-skill-spec MR !7 60df610 — narrative now describes how the
flags interact (XOR, colors length rule, highlight gating, sheet-prefix
read-back gotcha) without exposing the underlying data_validation field
names or server-side normalization details that agents don't act on.

No Go-side change, no shortcut behavior change.
2026-05-25 14:39:36 +08:00
zhengzhijie
f0d218f7ea feat(sheets): +dropdown-set/-update --source-range for listFromRange mode
Previously +dropdown-set / +dropdown-update only emitted
data_validation.type=list — agents wanting listFromRange (dropdown options
sourced from existing cells, kept in sync with that range) had to drop down
to +cells-set and hand-build a data_validation map. The flag now exposes it
natively as --source-range, paired with --options under XOR.

CLI changes:

- shortcuts/sheets/lark_sheet_write_cells.go:
  * new dropdownTypeAndItems(runtime) — central XOR resolver: rejects 0 or
    2 of {--options, --source-range}, returns (sourceSize, partial dv with
    type+items|range filled in). Source size = options length for list
    mode, rangeDimensions(--source-range) cell count for listFromRange.
  * buildDropdownValidation rewritten to call the resolver, then layer
    --colors / --multiple / --highlight on top — semantics unchanged
    for callers, just two modes instead of one.
  * validateDropdownOptions / -Colors renamed to validateDropdownSourceOrOptions
    so the XOR + length check fires at +dropdown-update Validate time too.
  * --colors length error message generalized: "must not exceed dropdown
    source size (N)" (covers both modes).
- shortcuts/sheets/lark_sheet_batch_update.go: rename call site.
- shortcuts/sheets/lark_sheet_write_cells_test.go: 4 new tests —
  ListFromRange (happy path: range + items absent + colors + highlight all
  emit), ListFromRange_ColorsLongerThanCells (overflow against T1:T3 cell
  count), XorBothSet, XorNeitherSet. Updated the existing
  ColorsLongerThanOptions assertion to match the new "source size" wording.

Spec-driven changes (synced via npm run sync:cli from sheet-skill-spec
MR !7 2c298b6):

- shortcuts/sheets/data/flag-defs.json: --options Required flips to xor on
  +dropdown-set/-update; new --source-range row gains long-form description
  pointing at server data_validation.range + the XOR semantics.
- skills/lark-sheets/references/lark-sheets-write-cells.md: "Dropdown 配色"
  section reorganized into "Dropdown 选项 + 配色" — XOR comparison table
  (list vs listFromRange), shared config flag table (--highlight /
  --colors), explicit length rule covering both modes, side-by-side
  minimal examples, server-range-normalization gotcha callout.
- skills/lark-sheets/references/lark-sheets-batch-update.md pointer updated
  to mention both modes + that +dropdown-delete is unaffected.

PPE smoke (ppe_lark_cli_sheet) on UFJxszjrZhZ1LVtc9FdcICSbn6b C column:
- +cells-set C1 → "性别" (bold + centered): updated_cells_count=1
- +dropdown-set --range C2:C21 --source-range "Sheet1!T1:T3" --colors
  '["#cce8ff","#ffd6e7","#e6e6e6"]' --highlight: updated_cells_count=20
- read-back: data_validation.type=listFromRange + range=$T$1:$T$3 (server
  normalizes the prefix away on storage; highlight_colors /
  enable_highlight not echoed by get_cell_ranges, see byted-sheet read
  projection TODO).
- error-path replay (both XOR violations + colors > source-size) all
  rejected at Validate stage with the expected messages.
2026-05-25 14:39:36 +08:00
zhengzhijie
09c02e8657 docs(sheets): sync dropdown --colors/--highlight clarification from spec
Mirrors sheet-skill-spec MR !7 changes:

- skills/lark-sheets/references/lark-sheets-write-cells.md: new "Dropdown
  配色" section explaining how --colors (→ data_validation.highlight_colors)
  and --highlight (→ data_validation.enable_highlight) compose — length
  rule (shorter ok, longer rejected), --highlight gating, palette
  fallback behavior, minimal +dropdown-set example.
- skills/lark-sheets/references/lark-sheets-batch-update.md: one-line
  pointer to the write_cells section for +dropdown-update / -delete
  (same rules).
- shortcuts/sheets/data/flag-defs.json: --colors / --highlight `desc`
  fields gain the long-form server-field / length-rule descriptions
  used by `--help`.

No Go-side change — earlier commit 538eb2e already loosened the
buildDropdownValidation length check to "must not exceed"; this PR step
just makes the docs / `--help` text catch up.
2026-05-25 14:39:36 +08:00
zhengzhijie
2ee2a59dff chore(sheets): re-sync from spec + loosen --colors length check
Catches up to sheet-skill-spec's 2026-05-25 base sync (MR !7) after
rebasing onto upstream feat/lark-sheets-refactor (12 new upstream commits
including the lark-sheets skill refactor + tools-schema migration).

Spec changes flowing in:

- highlight_colors description loosened: length may be **shorter than**
  --options (server cycles remaining slots through a built-in 10-color
  palette); previously the tool errored on any length mismatch.
- shortcuts/sheets/data/flag-schemas.json: mass re-mirror — generator now
  emits `type` before `properties` and adds explicit `additionalProperties:
  false` on object schemas (cosmetic, no behavior change).
- skills/lark-sheets/references/lark-sheets-{batch-update,chart,write-cells}.md:
  --options gains the type='list' tag; data_validation inline field-count
  goes 7 → 9 (catches up the highlight schema in the summary); chart
  position / size marked optional per upstream.

Go-side adjustment:

- buildDropdownValidation / validateDropdownOptionsColors: change the
  --colors length check from strict-equal to "must not exceed --options"
  to match the relaxed schema.
- TestDropdownSet_ColorsLengthMismatch -> TestDropdownSet_ColorsLongerThanOptions
  (now hits the overflow path with 3 colors vs 2 options).
- New TestDropdownSet_ColorsShorterAccepted: 2 colors vs 4 options is
  legal and forwarded as-is.
2026-05-25 14:39:36 +08:00
zhengzhijie
96c338735a fix(sheets): restore +dropdown --colors / --highlight, map to canonical fields
Reverses the --colors / --highlight removal from 7932ab2 (item #2 of the
batch-1 schema-alignment commit). That commit dropped both flags after the
test report flagged data_validation.colors / highlight_options as "unexpected
property" — at the time the canonical set_cell_range.data_validation schema
listed only help_text / items / operator / range / support_multiple_values /
type / values, so the flags had no server-side target and the removal was
correct.

Since then, set_cell_range.data_validation has gained two fields explicitly
modelling the dropdown highlight UI (mcp-tools.json in sheet-skill-spec
2026-05-22 base sync):

  enable_highlight  (bool)       — show pill backgrounds
  highlight_colors  (string[])   — hex pill colors, length must match items

So the flags are back, but rewired:

  --colors    -> data_validation.highlight_colors    (was: colors)
  --highlight -> data_validation.enable_highlight    (was: highlight_options)

--options -> items and --multiple -> support_multiple_values renames from
7932ab2 are kept.

Changes:

- buildDropdownValidation: re-add --colors / --highlight handling against
  the new field names; --colors length check stays inline (so dropdownSetInput
  Validate path catches it via validateViaInput, no separate guard needed).
- validateDropdownOptions -> validateDropdownOptionsColors: restore the
  Validate-time --colors length check on +dropdown-update / +dropdown-delete
  (called from lark_sheet_batch_update.go).
- TestDropdownSet_CellsShape: extend to assert highlight_colors /
  enable_highlight emitted; assert legacy `colors` / `highlight_options`
  absent.
- TestDropdownSet_ColorsLengthMismatch: new — covers the early Validate
  error path.
- TestDropdownUpdate_BatchPayload: extend to cover dropdownBatchInput
  propagation of --colors / --highlight through batch_update.
- skills/lark-sheets/references/lark-sheets-{write-cells,batch-update}.md,
  shortcuts/sheets/data/flag-defs.json, flag-schemas.json: synced from
  sheet-skill-spec generate output (MR !7).
2026-05-25 14:39:36 +08:00
zhengzhijie
101c572d64 docs(sheets): sync from sheet-skill-spec — remove dropdown --colors / --highlight
Upstream sheet-skill-spec base table deleted the --colors and --highlight
flags on +dropdown-set / +dropdown-update (the corresponding wire fields
data_validation.colors / .highlight_options were never accepted by the
server schema; see prior fix in this branch). Re-running the sync from
canonical-spec brings the CLI flag-defs and skill reference docs back in
line with the Go-side handling that already drops these fields.

Generated by `npm run sync:cli` in sheet-skill-spec @ ac7acef.
2026-05-25 14:39:36 +08:00
zhengzhijie
9d06652aa9 revert(sheets): drop direct edits to skills/lark-sheets/references/
These md files are sync targets generated from sheet-skill-spec; editing
them here gets clobbered on the next sync, same as data/flag-defs.json.
The --colors / --highlight row removals belong on the upstream base
table → canonical-spec sync, not here.

Restore the previous --colors / --highlight rows in both
lark-sheets-write-cells.md (+dropdown-set) and lark-sheets-batch-update.md
(+dropdown-update). The Go-side change in buildDropdownValidation still
drops the wire fields, so:

  - users passing --colors / --highlight today see the flag accepted
    silently (no effect on the wire) until upstream removes the flag;
  - after upstream removal + sync, both flag declarations, ref docs, and
    Go-side handling will be in sync.

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
2026-05-25 14:39:36 +08:00
zhengzhijie
5926e89ce3 revert(sheets): drop direct flag-defs.json edits — generated from spec
data/flag-defs.json is regenerated from the upstream sheet-skill-spec
canonical-spec; editing it here gets clobbered on the next sync. The
schema realignment for +dropdown-set / -update --colors / --highlight
removal needs to land on the base table first, then flow back through
sheet-skill-spec → larksuite-cli sync, not via a direct CLI-side edit.

Restore the previous flag entries verbatim. The Go-side change in
buildDropdownValidation still drops the wire fields, so:

  - users passing --colors / --highlight today see the flag accepted
    silently (no effect on the wire) until the upstream removal lands;
  - after upstream removal + sync, both the flag declarations and the
    Go-side handling will be in sync.

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
2026-05-25 14:39:36 +08:00
zhengzhijie
556b2292c7 fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema
Five separate E2E failures in shortcuts/sheets/ that all trace back to a
CLI ↔ server contract mismatch. Each is independently scoped; bundling
them because they share the test-report citation and the same one-line
fix shape in most cases.

buildInitialFillInput sent {"sheet_id": ""} on the secondary
set_cell_range call after creating the workbook. The empty value was a
holdover from "...otherwise server picks first sheet" — but
set_cell_range rejects an empty selector with
"sheet_id or sheet_name is required" rather than falling back to the
default sheet.

Use sheet_name "Sheet1" instead. POST /sheets/v3/spreadsheets always
creates that sheet on workbook creation, and set_cell_range accepts
sheet_name as an equivalent selector — saves an extra
get_workbook_structure round-trip just to learn the auto-generated id.

buildDropdownValidation emitted four fields that don't exist in the
canonical set_cell_range.data_validation schema:

  - "values" (options list)       → renamed to "items"
  - "multiple_values"              → renamed to "support_multiple_values"
  - "colors" (per-option color)    → removed (not in schema; flag also
                                     removed from data/flag-defs.json
                                     for +dropdown-set / -update)
  - "highlight_options"            → removed (not in schema; flag also
                                     removed)

The canonical schema lives at sheet-skill-spec/canonical-spec/tool-
schemas/mcp-tools.json (set_cell_range tool, data_validation property);
the colors / highlight knobs were CLI inventions the server never
accepted, so removing the flags is correct (renaming would leave the
flags broken). Skill reference docs (write-cells.md, batch-update.md)
synced.

validateDropdownOptionsColors lost its colors check; renamed to
validateDropdownOptions to reflect the narrower contract.

dropdownGetInput sent "Sheet1!C2:C6" verbatim as a ranges[] entry.
get_cell_ranges expects sheet_id / sheet_name as separate fields and
ranges entries without the sheet prefix; the server bounced with
"sheet not found, sheetId:" (empty).

Use the existing splitSheetPrefixedRange helper (declared in
lark_sheet_batch_update.go) to break "Sheet1!C2:C6" into ("Sheet1",
"C2:C6"), then thread the sheet name through sheetSelectorForToolInput
exactly like +cells-get does.

The shortcut was POSTing to /sheets/v2/spreadsheets/{token}/dimension_
range, which is the v2 insert-dimension endpoint and requires a top-
level {"dimension": {...}} body. Move uses a separate endpoint:

  POST /sheets/v2/spreadsheets/{token}/move_dimension
  body: { "source": {...}, "destination_index": N }

(camelCase "destinationIndex" → snake_case "destination_index" to
match the v2 contract.) Both DryRun and Execute updated, plus the
TestDimMove_DryRun and TestExecute_DimMove assertions.

transform_range.sort_conditions[i] requires both `column` (string) and
`ascending` (bool); rangeSortInput passed the --sort-keys array through
to the server unvalidated, so missing fields surfaced as opaque
"required property X missing" errors with no per-item context.

Walk the parsed array client-side, reject with item-pointing messages.
Test fixtures and a contract-test fixture switched from the historical
{col, order} vocabulary (which the server has never accepted) to the
correct {column, ascending}.

Server-schema citations and test-report case mapping in this branch's
plan file.
2026-05-25 14:39:35 +08:00
xiongyuanwen-byted
4be06c85f6 fix(sheets): correct +workbook-create initial fill and +dim-move endpoint
+workbook-create: the v3 create response does not echo the default sheet's id, so the initial-fill set_cell_range was sent with an empty sheet_id and rejected ("sheet_id or sheet_name is required"). Resolve the workbook's first sheet via get_workbook_structure before filling.

+dim-move: the move request was POSTed to the v2 dimension_range endpoint (the add/update/delete surface, which requires a `dimension` object) and rejected with "[9499] Missing required parameter: Dimension". Switch to the native v3 move_dimension endpoint (sheet_id in path; snake_case source.{major_dimension,start_index,end_index} + destination_index). CLI --end and v3 end_index are both 0-based inclusive, so they pass through unchanged.
2026-05-25 14:15:48 +08:00
xiongyuanwen-byted
868beaf004 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs from upstream sheet-skill-spec (core-operations,
formula-translation, visual-standards, SKILL guidance). Description-only;
no flag or CLI behavior change.
2026-05-25 11:54:43 +08:00
xiongyuanwen-byted
81bb61359d docs(sheets): sync lark-sheets skill spec from upstream
Trim and refine reference docs from upstream sheet-skill-spec
(condense core-operations workflow, tidy write-cells / range-operations /
float-image / SKILL guidance). Description-only; no flag or CLI behavior
change.
2026-05-24 14:44:17 +08:00
xiongyuanwen-byted
370137e1c3 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec: clarify +workbook-export sheet flag scope, +filter-*
--properties optionality (omitted => empty filter on --range; rules must
be non-empty when provided), float-image reference_id wording, and
assorted reference cleanups. Description-only; existing CLI behavior
(filter passthrough, properties optional) already matches.
2026-05-24 09:55:47 +08:00
xiongyuanwen-byted
48e6072342 docs(sheets): sync chart properties schema (position/size required)
Regenerate flag-schemas.json from upstream sheet-skill-spec: the chart
properties schema now marks position and size as required, and the chart
reference doc reflects the same. flag-schemas.json is print-schema-only
(no client-side validation), so this is a generated-artifact + doc sync
with no CLI behavior change.
2026-05-23 19:09:02 +08:00
xiongyuanwen-byted
b85311c873 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec (--depth wording for +dim-group / +dim-ungroup,
plus assorted reference clarifications). Description-only; no CLI
behavior or flag surface change.
2026-05-23 13:45:44 +08:00
xiongyuanwen-byted
2f5c625ac7 docs(sheets): sync lark-sheets read docs — --max-chars as single read cap
Sync skills/lark-sheets references from spec: drop --cell-limit / --max-rows
guidance; 大表分批读 switches to --range row windows + --max-chars auto cap + has_more.
Mirrors sheet-skill-spec 58e7456 and handler change 2befc49.
2026-05-23 09:05:55 +08:00
xiongyuanwen-byted
e0c22d6ee0 fix(sheets): make --max-chars the single read cap for +cells-get / +csv-get
Drop --cell-limit (+cells-get) and --max-rows (+csv-get) from the CLI surface
and pin the underlying tool's cell_limit / max_rows to a very large sentinel so
the tool's own defaults never truncate before --max-chars. --max-chars stays the
only knob (default 200000, unchanged).

- lark_sheet_read_data.go: add unboundedReadLimit (1e9); cellsGetInput pins
  cell_limit, csvGetInput pins max_rows; --max-chars still passed through
- data/flag-defs.json: synced from spec (drops the two flags)
- tests: spot-check moved to --max-chars; dry-run wantInput asserts cell_limit /
  max_rows are pinned high

Mirrors sheet-skill-spec (Base flag records removed).
go build ./... + go test ./shortcuts/sheets/ green.
2026-05-23 08:59:11 +08:00
xiongyuanwen-byted
efcc55460b docs(sheets): sync lark-sheets skill spec (chart/pivot wire mappings, --end semantics)
Sync skill references and flag-defs descriptions from upstream
sheet-skill-spec: clarify +chart-create properties structure
(snapshot.data), +pivot-create --target-position / --range wire-field
mappings, add a cross-command --end endpoint-semantics table
(insert/delete/hide/group exclusive vs move/resize inclusive), note
--group-state default, and rename reference identifiers to lark-sheets-*.

Description-only refinement; the existing CLI implementation already
matches the clarified wire mappings and --end semantics.
2026-05-23 01:18:25 +08:00
xiongyuanwen-byted
e003d4aa01 feat(sheets): add +cells-set --copy-to-range and sync skill spec
Sync lark-sheets skill references and flag schemas from upstream
sheet-skill-spec, and wire the newly-specced --copy-to-range flag into
+cells-set: it passes copy_to_range to the set_cell_range tool so a
template block written via --cells fans out across a larger range with
auto-shifted formula refs.
2026-05-22 18:10:45 +08:00
zhengzhijiej-tech
d91341bca3 Merge pull request #1023 from zhengzhijiej-tech/fix/sheets-audit-20260521
fix(sheets): apply lark-sheets audit 20260521 + sync spec docs
2026-05-21 22:10:47 +08:00
zhengzhijie
5a42fb5788 docs(sheets): sync lark-sheets skill from spec (audit 20260521)
Pull latest spec from sheet-skill-spec (PR ee/sheet-skill-spec!6 + earlier
develop commits) into skills/lark-sheets/ and shortcuts/sheets/data/.

Audit findings now reflected in CLI docs:

- A2 +cond-format-create example: --rule-type duplicate → duplicateValues
- A3 +cond-format-create Validate: cellValue/formula → cellIs/expression
- A5 +csv-put examples: --range → --start-cell; drop redundant --allow-overwrite
- A7 +sparkline-create: Validate / Examples aligned with real schema
  (config/sparklines), executable JSON example added
- B13 cross-doc dead links: lark_sheet_*/cli-shortcuts.md → lark-sheets-*.md
- C2 +csv-put: `=` literal warning next to Examples
- CC5 +rows-resize/+cols-resize --type auto: single point of truth in
  range-operations reference

flag-defs.json description / required sync (from base):

- A4 +float-image-update: image-name/position-*/size-* required → optional
  (patch mode)
- A8 +dim-move --start/--end description cleanup
- B3 +pivot-create --properties: data_range → source (real field name)

Also picks up the +cells-batch-clear shortcut doc (introduced in spec
develop). Go-side implementation for that shortcut is intentionally not
in this PR — docs-only preview; runtime dispatch will land in a follow-up.

`go test ./shortcuts/sheets/...` passes.
2026-05-21 20:55:29 +08:00
zhengzhijie
d914c851ac fix(sheets): require sparkline_id on +sparkline-update items (#6)
manage_sparkline_object uses two layers of IDs: --group-id picks the
sparkline group, and properties.sparklines[i].sparkline_id picks each
item inside the group. The server contract requires sparkline_id on
every update item (server maps each entry back to an existing
sparkline by this id). Agents that called +sparkline-update without
the per-item ids hit an opaque server-side rejection that didn't
mention sparkline_id at all, then got stuck in a try-fail-list-retry
loop.

Pre-check CLI-side in objectUpdateInput via a new validateUpdateInput
hook on objectCRUDSpec. sparklineSpec wires validateSparklineUpdateItems,
which walks properties.sparklines[] and rejects with a message that
points at +sparkline-list:

  +sparkline-update properties.sparklines[N] missing sparkline_id
  (run `+sparkline-list --group-id <id>` first to read sparkline_id
   for each item, then echo each id back on the corresponding update
   entry)

Scope is update-only. config-only updates (properties.config without
sparklines) stay legal — the validator skips when sparklines is
absent. Delete is not pre-checked: objectDeleteInput doesn't pass
properties through, so the partial-delete branch can't be reached
today (separate follow-up).

Tests:

- TestObjectCRUDShortcuts_DryRun: positive case for update with
  sparkline_id present.
- TestSparklineUpdate_MissingSparklineID: standalone path — error
  contains both "missing sparkline_id" and "+sparkline-list".
- TestBatchOp_RejectsBadSubOpInput: batch sub-op missing sparkline_id
  rejected with the same friendly error.

Docs synced from sheet-skill-spec (canonical change committed there):
skills/lark-sheets/references/lark-sheets-sparkline.md documents the
two-layer id model, the three "+sparkline-list first" cases, and both
delete modes.
2026-05-21 20:55:29 +08:00
xiongyuanwen-byted
300a5e8906 docs(sheets): sync stdin guidance and sparkline reference
- skills/lark-shared/SKILL.md: drop the generic "prefer stdin" section
- skills/lark-sheets/SKILL.md: add expanded stdin guidance (use stdin over @file abs paths; don't cd or write into the project dir)
- skills/lark-sheets/references/lark-sheets-sparkline.md: document the group_id / sparkline_id two-tier model with worked examples
2026-05-21 20:31:58 +08:00
xiongyuanwen-byted
5f3e8c6385 feat(sheets): add +cells-batch-clear fan-out over batch_update
Clear content/formats across many sheet-prefixed ranges in a single atomic
batch_update (one clear_cell_range op per range), mirroring the existing
+cells-batch-set-style / +dropdown-{update,delete} fan-out wrappers. The
--scope to clear_type normalization is shared with standalone +cells-clear
(normalizeClearType) so the two stay in lockstep.

high-risk-write (requires --yes); rejected as a batch sub-op like the other
fan-out wrappers. flag-defs/flag-schemas and skill docs updated to match.
2026-05-21 19:17:33 +08:00
zhengzhijiej-tech
8e8a5110ee Merge pull request #1018 from zhengzhijiej-tech/fix/sheets-validation-pushdown
fix(sheets): +batch-update sub-op CLI-side validation + cond-format / filter schema alignment
2026-05-21 17:20:15 +08:00
zhengzhijie
4e44e668f7 fix(sheets): align +cond-format / +filter with server schema (#4 + #5)
Two latent bugs in the object_crud translator surfaced during BOE smoke
testing of +batch-update. Both are schema-alignment fixes against
manage_conditional_format_object / manage_filter_object as declared in
sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json.

#4 +cond-format: rule_type path + enum vocabulary
---------------------------------------------------
condFormatEnhance used to write the user's --rule-type value into
`properties.rule.type` (nested under a `rule` object). The server
schema actually puts it at flat `properties.rule_type` and silently
drops the nested form — so every conditional-format create/update
secretly built the wrong document.

Worse, the CLI enum exposed via flag-defs.json was its own invented
vocabulary (cellValue / formula / duplicate / unique / topBottom /
aboveBelowAverage / dataBar / colorScale / iconSet / textContains /
dateOccurring / blankCell / errorCell) — none of those values were
the strings the server accepts.

Fix:
- condFormatEnhance now writes `properties.rule_type = <value>`
  directly (no nested `rule` object).
- Synced flag-defs.json + lark-sheets-conditional-format.md enum
  vocabulary from base to match the server: duplicateValues,
  uniqueValues, cellIs, containsText, timePeriod, containsBlanks,
  notContainsBlanks, dataBar, colorScale, rank, aboveAverage,
  expression, iconSet.
- ⚠️ Breaking: scripts passing the old CLI-invented enum values
  (e.g. --rule-type cellValue) now get a cobra "invalid value …
  allowed: …" error listing the new vocabulary. No alias layer.
- TestObjectCRUDShortcuts_DryRun's +cond-format-update case updated
  to assert the flat properties.rule_type shape + new enum.

#5 +filter-{update,delete}: auto-inject filter_id = sheet_id
-------------------------------------------------------------
manage_filter_object's contract is "filter_id === sheet_id" for the
sheet-scoped filter (per per-tool description in mcp-tools.json),
and update / delete operations MUST carry filter_id. Standalone
filterUpdateInput / filterDeleteInput never set it, so the server
rejected with "filter_id is required for update/delete operation"
on every call — both standalone AND inside +batch-update.

Fix:
- filterUpdateInput / filterDeleteInput now set
  input["filter_id"] = sheetID.
- Because filter_id must equal sheet_id (not sheet_name), update /
  delete reject when only --sheet-name is given — there's no
  network lookup available inside the builder. The friendly error
  points at +workbook-info for resolving sheet-name → sheet-id.
- create still omits filter_id (server requires that — id is
  server-allocated on creation).
- New tests:
  * TestObjectCRUDShortcuts_DryRun gains a +filter-update happy-path
    case asserting filter_id is auto-injected + --range hoisting.
  * +filter-delete case updated to assert filter_id presence.
  * TestBatchOp_RejectsBadSubOpInput gains two cases asserting both
    +filter-update and +filter-delete reject --sheet-name-only with
    the friendly error.

Docs (#2 + #3 + #8) synced from sheet-skill-spec
-------------------------------------------------
Companion doc fixes that landed via npm run generate:cli + sync:cli
in sheet-skill-spec; included here because the regenerated flag-defs
and references markdown are byte-tracked in this repo:

- #2: lark-sheets-sheet-structure.md — +dim-{hide,unhide,group,
  ungroup} --start/--end desc changed from "(0-based, inclusive)" to
  "(0-based)" / "(exclusive)" to match the half-open range semantics
  the code has always implemented (requireDimRange: end > start;
  dimRange uses end - 1 for column end letters).
- #3: lark-sheets-workbook.md — +sheet-move section gains a note
  about the batch-internal requirement to pass --sheet-id AND
  --source-index explicitly (sheetMoveBatchInput's constraint).
- #8: lark-sheets-pivot-table.md — +pivot-create --properties
  example drops the stale data_range field (the actual server
  schema uses --source as a hoisted flag; properties only carries
  rows / columns / values / filters / show_*_grand_total).
2026-05-21 16:37:15 +08:00
zhengzhijie
8d0fefd9e0 fix(sheets): push +batch-update sub-op validation down into xxxInput builders
Sub-ops that omit --sheet-id (or any other required flag) used to slip
past CLI validation — Validate ran only against the standalone shortcut
path, and batchOpDispatch's translators built bodies from whatever
flagView returned, so a structurally broken sub-op surfaced as an opaque
server "sheet undefined not found" after a network round-trip.

Push each batchable shortcut's check trio down into its xxxInput builder:

  1. resolveSpreadsheetToken — stays in Validate (batch already does it
     once at the top level; sub-ops don't repeat).
  2. requireSheetSelector(sheetID, sheetName) — new helper; flagView-
     agnostic XOR + control-char check, called at the top of every
     xxxInput.
  3. shortcut-specific required / range / enum checks (--dimension,
     --range, --start <= --end, --type pixel needs --size,
     --float-image-id, image-token XOR image-uri, ...) — moved out of
     Validate into the builder body.

All ~30 batchable xxxInput builders now return (map, error). Standalone
Validate shrinks to validateViaInput(xxxInput); DryRun / Execute
propagate the error. batch_op_dispatch entries drop the noErrTranslate
wrapper and pass the builder directly — its error bubbles up wrapped
with "operations[N] (+shortcut):" context.

Tests:

- TestBatchOp_ErrorEquivalence (7 cases): XOR / logical-constraint
  errors fire identically from standalone and batch sub-op paths.
- TestBatchOp_RejectsBadSubOpInput (8 cases): cobra-required flags that
  standalone catches via MarkFlagRequired now also get rejected CLI-side
  on the batch path (where cobra is not in the loop).
- TestBatchOp_BodyMatchesStandalone (~40 cases) and
  TestBatchOp_DispatchCoversReportedBugs continue to pass — bodies stay
  byte-identical.
- BOE smoke (spreadsheet ICFwstkUGheyfptGWS2bB7RgcDf, sheet 51991c):
  +batch-update with a sub-op missing --sheet-id now returns
  "operations[0] (+dim-insert): specify at least one of --sheet-id or
  --sheet-name" before any network call.

sheetMoveBatchInput (xiongyuanwen's batch-only explicit-source-index
requirement) is preserved — it's an orthogonal batch-specific constraint
not affected by this push-down.
2026-05-21 16:37:00 +08:00
xiongyuanwen-byted
1e05e7b3ad fix(sheets): align +cells-get/+csv-get range flags with synced spec
sheet-skill-spec now declares +cells-get --range as a single string
(was string_array) and +csv-get --range as required. Match the
flag→body translators:

- +cells-get wraps the single --range into the tool's `ranges` array
  and validates with Str() instead of StrArray(), which silently
  returned nil against the now-String flag and broke the command.
- +csv-get gains a trim-based required-range guard.

Update read-data dry-run tests to single-range form and add a guard
test for the empty --range path.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
0ea7c14e4a fix(sheets): make +batch-update sub-ops reuse standalone flag→body translators
Sub-ops previously near-passed-through their input, so any shortcut whose
standalone translator renames fields broke inside a batch: +range-copy lost
range/destination_range (transform_range errored "range missing") and
+rows-resize lost range/resize_height ("No resize operation specified").

Introduce a flagView interface (satisfied by *common.RuntimeContext) and a
map-backed mapFlagView, then route every batchable sub-op through the SAME
*Input builder the standalone shortcut uses. mapFlagView seeds flag-defs.json
defaults for value reads while keeping Changed() user-driven, so a sub-op body
is byte-identical to the standalone body — locked by a batch-vs-standalone
contract test over all ~40 batchable shortcuts.

Also fix single-row/column resize: start==end now formats as "23:23" / "C:C"
(resize_range rejects a bare "23"); dimRangeFull keeps both sides while
dimRange's collapse stays for modify_sheet_structure consumers.
2026-05-21 12:42:47 +08:00
zhengzhijie
0c2e5f5e5c feat(sheets): translate +batch-update sub-ops {shortcut,input} → MCP shape
Users now hand +batch-update --operations a CLI-shape array
([{shortcut, input}, ...]) and the binary translates each sub-op to the
underlying MCP batch_update shape ({tool_name, input(+operation)}) via
a new dispatch table in shortcuts/sheets/batch_op_dispatch.go.

Dispatch table covers 50 batchable write shortcuts. Excluded by design:
- all read ops
- fan-out wrappers (+batch-update self, +cells-batch-set-style,
  +dropdown-update, +dropdown-delete) — nesting these = nested batch
- +dim-move — single shortcut uses legacy v2 /dimension_range endpoint,
  not MCP, can't be batched
- +cells-set-image — multi-step image upload, not atomic-batch friendly
- +workbook-create — new workbook, not batch-on-existing semantics

Translator also rejects sub-ops that hand-fill input.operation (implied
by shortcut name) or input.excel_id / spreadsheet_token / url (set
once at +batch-update top level).

+dim-freeze always injects operation=freeze; the count==0 unfreeze
path of the single shortcut is intentionally not supported in batch —
callers should use the single shortcut for unfreeze.

Tests cover: end-to-end translation, --continue-on-error propagation,
13 rejection cases (banned shortcuts, malformed shapes, reserved keys).

Sync'd from sheet-skill-spec: skills/lark-sheets/references/
lark-sheets-batch-update.md + shortcuts/sheets/data/flag-schemas.json
pick up the corrected enum (+cells-set-style / +dropdown-set added,
+dim-move removed).
2026-05-21 12:42:47 +08:00
zhengzhijie
9048c7097f docs(sheets): sync +batch-update CLI override schema (shortcut/input form)
Pulled from sheet-skill-spec:
- skills/lark-sheets/references/lark-sheets-batch-update.md: --operations
  now documents the {shortcut, input} form; tool_name references gone
- shortcuts/sheets/data/flag-schemas.json: --operations resolves to the
  CLI-side array<{shortcut(enum), input}> schema, sourced from spec's
  canonical-spec/tool-schemas/cli-schemas.json (cli: prefix). +dropdown
  --options also drilled one level deeper

NOTE: the binary still raw-passes --operations to MCP batch_update which
expects {tool_name, input}. A follow-up will add a shortcut→tool_name
translation layer (with per-shortcut operation field) before the docs
become actionable.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
3d3e2c7f10 refactor(sheets): build shortcut flags generically from flag-defs.json
Replace flag-descriptions.en.json with the richer flag-defs.json (full
flag definitions: type / default / enum / input / hidden / required /
kind) synced from sheet-skill-spec. Add flagsFor(command) to materialize
each shortcut's []common.Flag straight from the JSON, skipping
system-kind flags the framework injects.

Migrate every sheets shortcut (including the CRUD/list/dim/merge/
visibility factories) to Flags: flagsFor("+command"), dropping all
hand-written flag literals plus the now-dead publicTokenFlags /
publicSheetFlags / styleFlatFlags helpers and enum vars. A coverage test
locks the Go-flags-match-JSON contract.

Align Go with the new spec where they diverged: +cells-get --ranges →
--range, font-size int → float64, +filter-view-create --range now
required, +sheet-create row/col-count defaults 200/20.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ce852e26d8 feat(shortcuts): support int64 and float64 flag types
Flag.Type previously could not express non-integer numbers. Add int64
and float64 cases to flag registration plus Int64/Float64 runtime
accessors.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
460c794f28 feat(sheets): add flag-descriptions.en.json and wire applyFlagDescs into Shortcuts()
Embed data/flag-descriptions.en.json (synced from upstream spec) and
apply it at shortcut assembly time so every Flag.Desc is sourced from
the canonical JSON rather than hardcoded Go strings. Existing hardcoded
Desc values serve as fallback for flags not yet in the JSON.

Also sync reference doc updates from upstream.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
54914e6082 docs(sheets): remove sandbox references and normalize tool names to CLI shortcuts
Replace export_sheet_to_sandbox / import_sandbox_to_sheet / doubao_code_interpreter
with local-script + batch csv-get/csv-put workflows; unify legacy MCP tool names
(set_cell_range, get_range_as_csv, etc.) to CLI shortcut format (+cells-set, +csv-get).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
50190e8638 feat(sheets): add --print-schema runtime introspection for composite JSON flags
Composite JSON flags (--cells / --properties / --operations /
--border-styles / --sort-keys / --options) carry non-trivial structured
payloads. Reference docs cover top-level fields but agents writing
those flags often need the full JSON Schema to build a valid payload.

This adds a system-level introspection contract so any shortcut whose
flags are tracked upstream can serve its schemas locally:

  lark-cli sheets <shortcut> --print-schema --flag-name <name>
  lark-cli sheets <shortcut> --print-schema                  # list flags

The schema data is embedded at build time from a synced artifact
(shortcuts/sheets/data/flag-schemas.json). Upstream is the source of
truth — never hand-edit the JSON; update the source Base table and
rerun the sheet-skill-spec sync.

Framework changes (shortcuts/common):

- types.go: Shortcut gains an opt-in PrintFlagSchema hook
  (flagName -> bytes/error). When non-nil the framework auto-injects
  --print-schema / --flag-name and short-circuits Validate/Execute.
- runner.go: register the two system flags when PrintFlagSchema is
  set; intercept in runShortcut before identity/scope/config so
  pure-local lookups don't trigger auth or network. Install a
  PreRunE that relaxes cobra's required-flag gate when
  --print-schema is set, since asking for a schema shouldn't need
  unrelated required flags.

Sheets surface (shortcuts/sheets):

- flag_schema.go (new): go:embed data/flag-schemas.json; expose
  printFlagSchemaFor(command) closure. When flagName is empty it
  emits a JSON listing of introspectable flags for discovery;
  otherwise it returns the schema subtree as pretty JSON.
- flag_schema_test.go (new): cover embed parsing, listing /
  by-name lookup, unknown-flag error path, registration via
  Shortcuts(), and the full system-flag short-circuit through
  cobra (required flags relaxed, schema printed on stdout).
- shortcuts.go: Shortcuts() now wraps shortcutList() and attaches
  PrintFlagSchema to every command present in flag-schemas.json,
  so shortcuts opt in by being listed upstream — no per-shortcut
  boilerplate.
- data/flag-schemas.json (new, synced from sheet-skill-spec):
  19 entries, schema_version "2". Generated upstream from the Lark
  Base source-of-truth (see sheet-skill-spec
  scripts/fetch_cli_flag_schema_map.mjs); ships only per-flag
  subtrees (not the full mcp-tools.json) to keep tool internals
  out of the open-source repo.

Skill docs (skills/lark-sheets):

- SKILL.md: system-flag table gains --print-schema / --flag-name and
  an "Agent 使用提示" note steering agents to prefer --print-schema
  over guessing JSON shape from the cheatsheet.
- references/*.md: regenerated by upstream sync (Schemas-section
  boilerplate updated, plus accumulated upstream prose refinements).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
184949ff0c refactor(sheets)!: split +dim-resize into +rows-resize and +cols-resize
Sync to upstream spec change that splits the legacy +dim-resize shortcut
into +rows-resize and +cols-resize. Reasoning is that row vs column
resize has divergent semantics (only rows support auto-fit) and the
shared --dimension flag was hiding that.

Behavior changes (BREAKING):
- +dim-resize is removed; use +rows-resize or +cols-resize.
- --dimension and --reset flags are gone.
- --type enum replaces --size/--reset:
    pixel    (requires --size)
    standard (reset to sheet default; no --size)
    auto     (auto-fit row height; +rows-resize only)
- --end is now inclusive (was exclusive). Old "--start 0 --end 5"
  (5 rows) becomes "--start 0 --end 4".
- Wire payload for resize_height / resize_width changes from
  {value: N} | {reset: true} to {type: "pixel", value: N} |
  {type: "standard"} | {type: "auto"}.

Tests cover both shortcuts across pixel / standard / auto and the
new guard surface (--type pixel needs --size; standard/auto reject
--size; +cols-resize rejects --type auto; --end < --start).

Also pulls in synced reference docs for 5 skills (batch-update,
core-operations, range-operations, sheet-structure, visual-standards)
that update prose mentions of +dim-resize.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
347d80361d fix(sheets): repair cells-set-image rich_text embed payload
The server rejected set_cell_range calls from +cells-set-image with three
distinct errors: missing "text" property, missing image_width/image_height,
and unknown attachment_token field. Realign the rich_text element to the
embed-image schema (text/image_token/image_width/image_height) and decode
PNG/JPEG/GIF dimensions from the local file before the write.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
be31975f7e refactor(sheets): align batch_update + cells-set with synced reference docs
Sync to upstream reference doc updates for 9 skills:

- batch_update sub-ops: rewrite wire fields tool/params -> tool_name/input
  in CellsBatchSetStyle and DropdownUpdate/Delete fan-out (the actual
  server contract per Schemas section); update --operations flag desc
  and tests.
- +cells-set --cells: accept bare 2D matrix [[{cell},...],...] instead
  of envelope {"cells":[[...]]}; spec example shows bare-array form.
- sparkline createDataDesc enum: win_loss -> winLoss (camelCase).

All other doc changes (float-image flat flags, cond-format
--rule-type/--ranges, pivot create-only --source/--range, filter /
filter-view extra flags, chart --properties) were already aligned in
commit ce33315.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
2acff2b17f refactor(sheets): sync shortcut flags with sheet-skill-spec v0.5.0
Upstream hoisted a batch of high-frequency scalar fields out of --data
into independent flags and renamed several composite-JSON flags to
match their semantic content. CLI catches up.

Renames (drop-in, same payload semantics):

  - +cells-replace      --replace   → --replacement
  - +cells-set          --data      → --cells
  - +workbook-create    --data      → --values
  - +batch-update       --data      → --operations (now a bare array;
                                       still accepts the envelope form for
                                       back-compat with continue_on_error)

Flat-flag hoists out of --style / --data:

  - +cells-set-style / +cells-batch-set-style
      --style JSON drops; replaced by 11 flat style flags
      (--background-color / --font-color / --font-size / --font-style /
      --font-weight / --font-line / --horizontal-alignment /
      --vertical-alignment / --word-wrap / --number-format) plus
      --border-styles for the one field that's still nested. Both
      shortcuts share styleFlatFlags() + buildCellStyleFromFlags().
  - +cells-batch-set-style also drops the [{ranges, style}] array shape
      in favor of one --ranges + the same flat style flags applied to
      all of them.

Object CRUD --data → --properties everywhere (chart / pivot / cond-format
/ filter / filter-view / sparkline / float-image). Per-skill scalar
hoists merged into properties via an enhanceCreate/UpdateInput callback:

  - +pivot-create        adds --source (required), --range
                          (and continues to expose --target-sheet-id /
                          --target-position at top level)
  - +cond-format-{create,update}
                          adds --rule-type (enum) + --ranges (JSON array);
                          merged into properties.rule.type and
                          properties.ranges respectively
  - +filter-view-{create,update}
                          adds --view-name and --range; both override
                          their properties.* counterparts
  - +filter-update        adds first-class --range (was buried in --data)

Float-image is fully hoisted — no --properties flag at all. Ten flat
flags (--image-name / --image-token | --image-uri / --position-row /
--position-col / --size-width / --size-height / --offset-row /
--offset-col / --z-index) compose the properties block. Implemented as
its own factory (newFloatImageWriteShortcut) since it diverges from the
shared CRUD spec.

Tests track every flag renamed and add explicit cases for the new flag
combos. go test -race -cover stays at 60.3 %.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b9a1752095 refactor(sheets): inline cli-only shortcuts into their canonical skill files
Two naming cleanups:

  - lark_sheet_cli_only.go is gone. The four shortcuts it grouped
    (+workbook-create / +workbook-export / +dim-move / +cells-set-image)
    were bundled by their implementation pattern (legacy OAPI direct
    calls) rather than by canonical skill. The whole sheets package IS
    the CLI implementation, so "cli only" wasn't a meaningful grouping
    at the Go layer. Each shortcut now lives next to its skill peers:

      +workbook-create / +workbook-export → lark_sheet_workbook.go
      +dim-move                           → lark_sheet_sheet_structure.go
      +cells-set-image                    → lark_sheet_write_cells.go

    Per-skill shortcut counts now match tool-shortcut-map.json exactly
    (workbook: 11, sheet_structure: 9, write_cells: 5). Helpers
    (buildInitialFillInput, pollExportTask, downloadExportFile,
    dimMoveBody) move with their shortcuts; nothing else in the package
    referenced them.

  - testhelpers_test.go → helpers_test.go. The _test.go suffix already
    conveys "test"; the leading "test" was redundant. Matches the
    helpers.go naming convention.

Behavior unchanged. go test -race -cover stays at 60.5 %.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
96f5742511 test(sheets): cover all 70 shortcuts with dry-run + execute-path tests
Twelve _test.go files alongside the implementation, mirroring the legacy
package's coverage style:

  - testhelpers_test.go     shared rig: TestFactory + Mount + dry-run
                            capture + JSON-input decode + envelope helpers.
  - lark_sheet_*_test.go    one test file per implementation file (9
                            files), table-driven dry-run cases per shortcut
                            plus targeted validation guards.
  - execute_paths_test.go   end-to-end execute paths via httpmock stubs.
                            Covers callTool unwrap, JSON-string output
                            decoding, two-step lookup (+sheet-move),
                            batch_update fan-out, dropdown atomic writes,
                            and the legacy OAPI shortcuts (+workbook-create,
                            +dim-move) including CLI inclusive → API
                            half-open index conversion.

Test coverage on the sheets package is 60.5 % of statements with -race
clean, meeting the dev manual's ≥ 60 % patch-coverage gate.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
9898024392 feat(sheets): implement cli-only shortcuts (B8) — 70/70 complete
Land the four cli-only shortcuts that can't route through the One-OpenAPI
dispatcher (their backing capabilities aren't in mcp-tools.json):

  - +workbook-create   POST /open-apis/sheets/v3/spreadsheets
                       + optional set_cell_range follow-up that zips
                       --headers and --data into the first sheet starting
                       at A1.

  - +workbook-export   POST /open-apis/drive/v1/export_tasks (type=sheet)
                       → poll /export_tasks/:ticket up to ~30s
                       → optional GET /export_tasks/file/:file_token/download.
                       CSV mode requires --sheet-id (single sheet export).

  - +dim-move          POST /open-apis/sheets/v2/spreadsheets/:token
                                              /dimension_range
                       CLI is 0-indexed inclusive (--start / --end); the v2
                       endpoint expects half-open [startIndex, endIndex)
                       so the body uses endIndex = --end + 1. --sheet-name
                       is resolved client-side to sheet_id via
                       lookupSheetIndex when needed.

  - +cells-set-image   common.UploadDriveMediaAll
                       (parent_type=sheet_image, parent_node=token)
                       then callTool set_cell_range with cells carrying
                       rich_text: [{type:"embed-image", attachment_token, attachment_name}].
                       --range must be exactly one cell.

All four use runtime.CallAPI / DoAPI directly; only +cells-set-image
combines a legacy upload with the new One-OpenAPI for the second step
(set_cell_range is in mcp-tools.json so callTool is the right path).

This closes the migration: 70 shortcuts × 17 canonical skills × matching
the sheet-skill-spec v0.5.0 tool-shortcut-map.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
705844f312 feat(sheets): implement lark_sheet_batch_update shortcuts (B7)
Land 4 shortcuts that all funnel through the batch_update tool's atomic
operations array:

  - +batch-update            raw passthrough; --data carries the full
                             { operations: [{tool, params}, ...] } payload
                             plus optional continue_on_error. high-risk-write
                             since the caller may stuff anything inside.

  - +cells-batch-set-style   --data is [{ranges, style}, ...]; CLI flattens
                             each (entry × range) pair into a set_cell_range
                             op with a fan-out cells matrix carrying
                             cell_styles + border_styles.

  - +dropdown-update         --ranges + --options (+ --colors / --multiple /
                             --highlight) — installs/replaces one dropdown
                             across many ranges, each becoming a separate
                             set_cell_range op with data_validation in cells.

  - +dropdown-delete         --ranges — clears data_validation across many
                             ranges (high-risk-write).

Default is strict transaction: if any sub-tool fails the whole batch rolls
back. +batch-update exposes --continue-on-error to flip the policy; the
three fan-out shortcuts leave it strict (they're meant to be all-or-nothing).

Reinstates validateDropdownRanges + splitSheetPrefixedRange that were
removed during B3 → B7 relocation.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
1cbc049700 feat(sheets): implement object CRUD shortcuts (B6)
Land 21 shortcuts — three (create / update / delete) per object skill —
backed by the manage_<obj>_object tools dispatched on the operation
enum. Five standard objects (chart / cond-format / sparkline /
float-image / filter-view) share an objectCRUDSpec factory; pivot and
filter are special-cased.

Shared wire contract:
  excel_id + sheet_id|sheet_name + operation + [<obj>_id] + [properties]
CLI --data is passed through as the tool's `properties` field as-is, so
callers shape it per each object's spec doc.

Special cases:
  - pivot adds optional --target-sheet-id / --target-position on create
    (siblings of properties, not inside it).
  - cond-format exposes --rule-id (short CLI name) wired to the tool's
    conditional_format_id on the wire.
  - sparkline uses --group-id (higher-level object handle) instead of
    sparkline_id.
  - filter has no separate id flag — at most one filter per sheet, so
    filter_id is implicit. +filter-create promotes --range to a first-
    class flag (instead of burying it inside --data).
  - filter-view CRUD are `cli_status: cli-only` but
    manage_filter_view_object is in mcp-tools.json, so they go through
    callTool / One-OpenAPI alongside everything else.

All delete shortcuts are high-risk-write and require --yes.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ee4096f141 feat(sheets): implement object-list shortcuts (B5)
Land 7 read shortcuts, one per object skill — chart / pivot table /
conditional format / filter / filter view / sparkline / float image. All
share the same shape (public sheet selector + optional <obj>-id filter)
so they're declared via newObjectListShortcut + an objectListSpec.

Notes:
  - +cond-format-list exposes --rule-id, which is renamed to
    conditional_format_id on the wire (the tool's full field name).
  - +sparkline-list exposes --group-id (the higher-level handle); the
    tool also accepts sparkline_id, intentionally not surfaced.
  - +filter-list takes no id filter — at most one sheet-level filter
    per sheet, so the listing is already unique.
  - +filter-view-list is `cli_status: cli-only` but get_filter_view_objects
    is in mcp-tools.json and dispatches through the same One-OpenAPI
    endpoint; no special path required.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b3e99de06c feat(sheets): implement lark_sheet_range_operations shortcuts (B4)
Land 8 shortcuts across four canonical tools:

  - clear_cell_range  → +cells-clear      (high-risk-write)
  - merge_cells       → +cells-merge / +cells-unmerge
  - resize_range      → +dim-resize
  - transform_range   → +range-move / +range-copy / +range-fill / +range-sort

Three CLI↔tool vocabulary bridges live in this file:

  - +cells-clear: --scope content normalizes to the tool's clear_type
    "contents" (singular/plural spec mismatch is absorbed in the CLI).
  - +dim-resize: --size <px> wraps as resize_{height,width}:{value:N};
    --reset wraps as {reset:true}. The two flags are mutually exclusive
    and at least one is required.
  - +range-fill: CLI's five-valued --series-type collapses to the tool's
    binary fill_type — `copy` → "copyCells", anything else → "fillSeries"
    (the actual series progression is inferred server-side from the
    seed cells in --source-range).
  - +range-copy: --paste-type {values, formulas, formats} maps to the
    tool's {value_only, formula_only, format_only}; "all" omits the
    field entirely so the server applies its default.

+cells-clear is the second high-risk-write shortcut in the package;
the framework enforces --yes with exit code 10 as usual.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
0d351179e4 refactor(sheets): move +dropdown-update / +dropdown-delete to lark_sheet_batch_update
Follow-up to B3 after the spec re-mapped these two shortcuts to the
batch_update tool (atomic multi-range CRUD) instead of fan-out via
set_cell_range. Drop their Go implementations + helper validateDropdownRanges
+ splitSheetPrefixedRange from lark_sheet_write_cells.go and remove the
registrations from Shortcuts(); the shortcuts will reappear under
lark_sheet_batch_update during B7.

Also pull in the re-rendered reference docs:
  - skills/lark-sheets/references/lark-sheets-write-cells.md
  - skills/lark-sheets/references/lark-sheets-batch-update.md
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
8494534c8f feat(sheets): implement read_data / search_replace / write_cells shortcuts (B3)
Land 11 shortcuts across three canonical skills:

  - lark_sheet_read_data (3): +cells-get / +csv-get / +dropdown-get
  - lark_sheet_search_replace (2): +cells-search / +cells-replace
  - lark_sheet_write_cells (6): +cells-set / +cells-set-style / +csv-put
    / +dropdown-set / +dropdown-update / +dropdown-delete

+dropdown-get reads the data_validation field via get_cell_ranges with
the range carrying its own sheet prefix (no --sheet-id needed). The
fine-grained --include vocabulary (value / formula / style / comment /
data_validation) maps to the tool's coarse include_styles bool plus
value_render_option enum. +csv-get's --include-row-prefix=false strips
the [row=N] prefix client-side because the tool only emits the
annotated form.

+cells-search / +cells-replace flatten the tool's options sub-object
into four independent flags (--match-case / --match-entire-cell /
--regex / --include-formulas) per the flat-flag rule, then repack them on the way
in.

+cells-set takes a raw --data JSON body whose `cells` array must match
the --range dimensions. +cells-set-style fans a single --style block
out to every cell in the range via a new fillCellsMatrix helper; the
range parser (rangeDimensions / splitCellRef / letterToColumnIndex)
only accepts rectangular A1:B2 forms — whole-column / whole-row need
sheet totals and are deferred.

+dropdown-set fans the validation block out to one range; +dropdown-
update / +dropdown-delete iterate sheet-prefixed --ranges and call
set_cell_range sequentially (partial failure leaves earlier ranges
already mutated; the Tip calls this out). +dropdown-delete is
high-risk-write and requires --yes.

+cells-set-image stays deferred to the cli-only batch (needs the
shared local-file upload helper alongside +workbook-create / +dim-move
/ +workbook-export).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ae728fe7ec feat(sheets): implement lark_sheet_sheet_structure shortcuts (B2)
Add 8 shortcuts under the lark_sheet_sheet_structure canonical skill:
+sheet-info (get_sheet_structure) plus +dim-insert / +dim-delete /
+dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup
(modify_sheet_structure, dispatched by operation enum).

Two reusable conversion helpers cover the impedance mismatch between
the CLI surface and the tool input:

  - dimRange / dimPosition translate the CLI's 0-based exclusive-end
    range into the tool's 1-based A1 notation. row 5..8 becomes
    position "6" + count 3 (insert) or range "6:8" (range ops); column
    26..29 becomes "AA:AC".
  - infoTypeFromInclude maps the fine-grained --include vocabulary
    (row_heights / col_widths / merges / hidden_rows / hidden_cols /
    groups / frozen) to the coarse info_type enum the tool accepts;
    mixed categories collapse to "all".

+dim-delete is high-risk-write (irreversible row/column removal).
+dim-freeze --count 0 auto-dispatches to operation=unfreeze. +dim-group
accepts --depth for forward-compat with a future server-side nested
group endpoint but does not pass it through today.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b33a06c1e4 feat(sheets): implement lark_sheet_workbook shortcuts (B1)
Land the 8 modify_workbook_structure shortcuts that round out the
lark_sheet_workbook canonical skill alongside the existing +workbook-info:
+sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy
/ +sheet-hide / +sheet-unhide / +sheet-set-tab-color. All eight call
modify_workbook_structure via the One-OpenAPI invoke_write endpoint,
dispatched by the `operation` enum.

Helpers in helpers.go grow publicSheetFlags() / resolveSheetSelector() /
sheetSelectorForToolInput() / sheetSelectorPlaceholder() so future
sheet-level shortcuts share the public --sheet-id / --sheet-name XOR
treatment. +sheet-create intentionally drops the sheet selector pair since
create has no existing-sheet anchor (matches the spec fix in
tool-shortcut-map.json).

+sheet-delete is the first high-risk-write shortcut in the canonical
package; the framework requires --yes (exit code 10 otherwise).

+sheet-move's tool requires source_index in addition to target_index. The
CLI accepts an optional --source-index override and falls back to a
single get_workbook_structure read to derive it (and to resolve sheet_id
from --sheet-name). DryRun stays network-free by rendering <resolve>
placeholders for any field that would need that read.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
17a5f29306 refactor(sheets): rebuild lark-sheets on sheet-skill-spec canonical + One-OpenAPI
Restart lark-sheets as a spec-driven downstream. Skill content (SKILL.md
and 16 references covering 13 operations skills + 3 workflow skills,
including the standalone filter-view skill) is mirrored from the
sheet-skill-spec canonical-spec; do not hand-edit, change upstream and
rerun npm run sync:consumers.

Drop the 11 legacy shortcut sources (spreadsheet / sheet management,
cell ops, dropdown, filter-view, float image, etc.) and 10 associated
tests. Wire up the new sheet_ai/v2 One-OpenAPI single entry that
dispatches by tool_name with JSON-string input/output, and land the
first canonical shortcut +workbook-info as a template that exercises
the public token XOR pair, Risk tiering, and zero-side-effect DryRun.

sheet_ai_api.go provides callTool / invokeToolDryRun and bypasses
runtime.CallAPI's silent swallowing of non-envelope responses so
gateway and business errors from the new endpoint surface precisely.

The remaining 55 shortcuts will be designed and landed separately,
canonical skill by canonical skill.
2026-05-21 12:42:46 +08:00
416 changed files with 40079 additions and 19325 deletions

View File

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

View File

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

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.45] - 2026-06-01
### Features
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
- **platform**: Support multiple policy rules per plugin (#1182)
### Bug Fixes
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
- **whiteboard**: Fix whiteboard skill (#1180)
### Refactor
- **auth**: Update login hint and split-flow docs (#1201)
## [v1.0.44] - 2026-05-29
### Features
@@ -948,6 +964,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42

View File

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

View File

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

View File

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

View File

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

View File

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

167
cmd/auth/check_test.go Normal file
View File

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

View File

@@ -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
@@ -277,12 +279,18 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
"**Display order:** Output the URL first, then place the QR code image below the URL." +
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
}
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 +312,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 +333,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 +369,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 +418,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 +451,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 +472,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 {

View File

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

View File

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

View File

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

View File

@@ -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",
@@ -961,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
"final message of the turn",
"return control to the user",
"do not block on --device-code in the same turn",
"After the user confirms authorization in a later step",
"lark-cli auth login --device-code device-code",
"come back and notify",
"YOU must execute",
"lark-cli auth login --device-code <device_code>",
"Do NOT cache",
"lark-cli auth login --no-wait --json",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)

View File

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

View File

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

View File

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

View File

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

121
cmd/auth/scopes_test.go Normal file
View File

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

View File

@@ -117,6 +117,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

View File

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

View File

@@ -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 <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",
})

View File

@@ -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 <id>", cfgBase),
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <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{

View File

@@ -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 <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",
})

View File

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

View File

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

View File

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

View File

@@ -6,16 +6,17 @@ package config
import (
"context"
"fmt"
"net/http"
"github.com/charmbracelet/huh"
"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"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// configInitResult holds the result of the interactive config init flow.
@@ -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{
@@ -168,10 +177,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
}
// Step 1: Request app registration (begin)
httpClient := &http.Client{}
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
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 +210,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 +219,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

133
cmd/config/init_test.go Normal file
View File

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

View File

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

View File

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

View File

@@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
"version": p.Version,
"capabilities": p.Capabilities,
}
if p.Rule != nil {
entry["rule"] = p.Rule
if len(p.Rules) > 0 {
entry["rules"] = p.Rules
}
entry["hooks"] = map[string]any{
"observers": p.Observers,

View File

@@ -59,16 +59,20 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
"source_name": sourceName,
"denied_paths": active.DeniedPaths,
}
if active.Rule != nil {
out["rule"] = map[string]any{
"name": active.Rule.Name,
"description": active.Rule.Description,
"allow": active.Rule.Allow,
"deny": active.Rule.Deny,
"max_risk": active.Rule.MaxRisk,
"identities": active.Rule.Identities,
"allow_unannotated": active.Rule.AllowUnannotated,
if len(active.Rules) > 0 {
rules := make([]map[string]any, 0, len(active.Rules))
for _, r := range active.Rules {
rules = append(rules, map[string]any{
"name": r.Name,
"description": r.Description,
"allow": r.Allow,
"deny": r.Deny,
"max_risk": r.MaxRisk,
"identities": r.Identities,
"allow_unannotated": r.AllowUnannotated,
})
}
out["rules"] = rules
}
output.PrintJson(f.IOStreams.Out, out)
return nil

View File

@@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
MaxRisk: "read",
}
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rule: rule,
Rules: []*platform.Rule{rule},
Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourcePlugin,
Name: "secaudit",
@@ -83,12 +83,16 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
if got["denied_paths"] != float64(42) {
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
}
ruleMap, ok := got["rule"].(map[string]any)
rulesAny, ok := got["rules"].([]any)
if !ok || len(rulesAny) != 1 {
t.Fatalf("rules field missing or wrong shape: %v", got["rules"])
}
ruleMap, ok := rulesAny[0].(map[string]any)
if !ok {
t.Fatalf("rule field missing or wrong type")
t.Fatalf("rules[0] wrong type")
}
if ruleMap["name"] != "secaudit" {
t.Errorf("rule.name = %v", ruleMap["name"])
t.Errorf("rules[0].name = %v", ruleMap["name"])
}
}
@@ -101,7 +105,7 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
t.Cleanup(cmdpolicy.ResetActiveForTesting)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rule: &platform.Rule{Name: "my-yaml-rule"},
Rules: []*platform.Rule{{Name: "my-yaml-rule"}},
Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml",

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
)
@@ -152,7 +153,9 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
}
}
httpClient := &http.Client{}
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp"
type probeResult struct {

View File

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

View File

@@ -10,6 +10,7 @@ import (
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := levenshtein(input, def.Key); d <= threshold {
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -69,34 +70,3 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -10,27 +10,6 @@ import (
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string

70
cmd/flag_suggest_test.go Normal file
View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -36,47 +36,71 @@ const userPolicyFileName = "policy.yml"
// pluginRules carries Plugin.Restrict() contributions collected from
// the InstallAll phase; nil/empty is fine.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
yamlPath, err := userPolicyPath()
if err != nil {
// No user home dir means we cannot locate the policy. Treat
// the same as "file missing": no pruning, no error. This keeps
// non-interactive CI environments (no HOME set) running.
yamlPath = ""
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
// yaml). When a plugin contributed rules we therefore do NOT even
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
// error once a plugin is present, so reading a malformed yaml here
// would let an unrelated broken file on the user's machine abort a
// plugin-governed binary -- exactly the file the plugin is supposed
// to shadow. Skipping the read keeps the shadow contract honest.
var (
yamlRules []*platform.Rule
yamlPath string
)
if len(pluginRules) == 0 {
p, perr := userPolicyPath()
if perr != nil {
// No user home dir means we cannot locate the policy. Treat
// the same as "file missing": no pruning, no error. This keeps
// non-interactive CI environments (no HOME set) running.
p = ""
}
yamlPath = p
loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath)
if lerr != nil {
// Yaml-only failures are fail-OPEN at the caller (warn and
// continue), but the active-policy snapshot is process-global
// and may still carry data from a previous build in long-lived
// embedders / tests. Clear it explicitly so `config policy
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return lerr
}
yamlRules = loaded
}
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
if err != nil {
// Yaml-only failures are fail-OPEN at the caller (warn and
// continue), but the active-policy snapshot is process-global
// and may still carry data from a previous build in long-lived
// embedders / tests. Clear it explicitly so `config policy
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return err
}
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: pluginRules,
YAMLRule: yamlRule,
YAMLRules: yamlRules,
YAMLPath: yamlPath,
})
if err != nil {
cmdpolicy.SetActive(nil)
return err
}
if rule == nil {
if len(rules) == 0 {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil
}
engine := cmdpolicy.New(rule)
// RuleName attributes a denial to a specific rule in the envelope.
// With a single rule that is unambiguous and preserves the legacy
// envelope verbatim; with several rules a denial means "no rule
// granted it", which has no single owner, so the field is left empty
// and reason_code=no_matching_rule carries the meaning instead.
ruleName := ""
if len(rules) == 1 {
ruleName = rules[0].Name
}
engine := cmdpolicy.NewSet(rules)
decisions := engine.EvaluateAll(rootCmd)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rule: rule,
Rules: rules,
Source: source,
DeniedPaths: len(denied),
})

View File

@@ -13,6 +13,8 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -184,6 +186,39 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
}
}
// When a plugin contributed rules, a malformed user policy.yml must NOT
// abort: plugin rules shadow yaml entirely, so the broken file is never
// read. Regression -- previously LoadYAMLPolicy ran first and an
// unrelated broken yaml on the user's machine could fatal a
// plugin-governed binary (build.go fail-CLOSES on policy errors when a
// plugin is present).
func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
cfgDir := tmpHome(t)
t.Cleanup(cmdpolicy.ResetActiveForTesting)
writePolicy(t, cfgDir, "::: not yaml :::") // broken on purpose
pluginRules := []cmdpolicy.PluginRule{
{PluginName: "secaudit", Rule: &platform.Rule{
Name: "docs-only",
Allow: []string{"docs/**"},
MaxRisk: "write",
}},
}
root := fakeTree(t)
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
}
// Plugin rule actually applied: im/+send is outside docs/** -> hidden.
if send := findLeaf(t, root, "im", "+send"); !send.Hidden {
t.Errorf("im/+send should be hidden by plugin rule (not in docs/** allow)")
}
// docs/+update is within allow and at/below max_risk -> stays visible.
if update := findLeaf(t, root, "docs", "+update"); update.Hidden {
t.Errorf("docs/+update should remain visible under plugin rule")
}
}
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
// Resolve and produces an error. This is the safety contract: a typo in
// the rule must not silently lower the pruning bar.

View File

@@ -4,29 +4,30 @@
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"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -201,43 +202,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 +273,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) {
@@ -326,6 +310,12 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
@@ -349,10 +339,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
}
unknown := args[0]
available := availableSubcommandNames(cmd)
suggestions := suggest.Closest(unknown, available, 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
@@ -363,6 +355,7 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
},
},
@@ -385,6 +378,81 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
return subs
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
// the valid_flags detail).
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
@@ -417,65 +485,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
}

View File

@@ -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)",
},

View File

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

View File

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

View File

@@ -113,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)

View File

@@ -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: <msg>" (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_<name>.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_<service>.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 24 — restricted to framework code, no business sweep:
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
### Business-domain migration (self-service, no central timeline)
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
### Legacy removal
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
### Before / after at a call site
```go
// before (legacy)
@@ -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

View File

@@ -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"},

View File

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

View File

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

View File

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

View File

@@ -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_<name>.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"
)

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ You should see `audit` in the plugin list.
| `Observer` | Before / After each command | No (fire-and-forget audit) |
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees |
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
### Plugin lifecycle
@@ -102,10 +102,17 @@ the rejected dispatch.
- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder
flips it automatically; the lower-level `Plugin` interface rejects
the mismatch with `restricts_mismatch`.
- Only ONE plugin per binary can call `Restrict()`. Multi-plugin
Restrict is a deliberate `plugin_conflict` error (single-rule
ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is
shadowed by any plugin Restrict.
- A plugin may call `Restrict()` more than once; each call adds one
scoped Rule and the engine combines them with **OR** — a command is
allowed when it satisfies every axis (allow / deny / max_risk /
identities) of at least one rule. Note a rule's `deny` is scoped to
that rule only and cannot veto another rule's allow. Only ONE plugin
per binary may contribute rules, though: two DISTINCT plugins each
calling `Restrict()` is a deliberate `multiple_restrict_plugins` error
(single-owner assumption — an independent plugin must not be able to
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
may itself list several rules under `rules:`) is shadowed by any plugin
Restrict.
- The `Wrap` factory runs **once per command dispatch**, not at
install time. Long-lived state (clients, caches, metrics counters)
must live on the Plugin struct or in package-level variables.
@@ -115,7 +122,8 @@ the rejected dispatch.
- Commands missing a `risk_level` annotation are denied by default
when a Rule is active. Set `Rule.AllowUnannotated = true` (or
`allow_unannotated: true` in yaml) to opt out during gradual
adoption.
adoption. With several rules this is per-rule: an unannotated command
is allowed as long as one rule that opts in also grants it.
- Risk annotation typos (e.g. `"wrtie"`) are always denied with
`risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated`
does NOT bypass this — typo is a code bug, not a missing
@@ -144,8 +152,7 @@ messages are localised and may change between releases.
| `duplicate_hook_name` | Same hook name registered twice within a plugin | Yes |
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes |
| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes |
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
| `install_panic` | `Plugin.Install` panicked | Yes |
@@ -165,6 +172,7 @@ might also be lying about being `FailOpen`).
| `write_not_allowed` | Command risk is `write` / `high-risk-write` and exceeds Rule `max_risk` |
| `risk_too_high` | Command risk exceeds Rule `max_risk` but is not a write (reserved for future risk levels) |
| `identity_mismatch` | Command's `supportedIdentities` does not intersect Rule `identities` |
| `no_matching_rule` | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
The `detail.layer` field distinguishes who rejected the call:

View File

@@ -37,7 +37,7 @@ type Builder struct {
caps Capabilities
actions []func(Registrar)
rule *Rule
rules []*Rule
hookNames map[string]bool
errs []error
@@ -125,7 +125,8 @@ func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler)
// sets Restricts=true and FailurePolicy=FailClosed (the framework
// requires both to coexist; the builder enforces the pairing so the
// plugin author cannot accidentally ship a policy plugin under
// FailOpen).
// FailOpen). It may be called more than once; each call adds one scoped
// Rule and the engine OR-combines them.
func (b *Builder) Restrict(rule *Rule) *Builder {
if rule == nil {
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
@@ -133,7 +134,14 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
}
b.caps.Restricts = true
b.caps.FailurePolicy = FailClosed
b.rule = rule
// Defensive clone: capture an independent snapshot so a caller that
// reuses and mutates the same *Rule across multiple Restrict calls
// gets distinct entries (mirrors the staging registrar's clone).
cp := *rule
cp.Allow = append([]string(nil), rule.Allow...)
cp.Deny = append([]string(nil), rule.Deny...)
cp.Identities = append([]Identity(nil), rule.Identities...)
b.rules = append(b.rules, &cp)
return b
}
@@ -143,7 +151,7 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
// The Restrict + FailOpen mismatch is checked here, not in the chained
// setters, because the two methods may be called in either order.
func (b *Builder) Build() (Plugin, error) {
if b.rule != nil && b.caps.FailurePolicy == FailOpen {
if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
b.errs = append(b.errs, errors.New(
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
}
@@ -155,7 +163,7 @@ func (b *Builder) Build() (Plugin, error) {
version: b.version,
caps: b.caps,
actions: b.actions,
rule: b.rule,
rules: b.rules,
}, nil
}
@@ -198,15 +206,15 @@ type builtPlugin struct {
version string
caps Capabilities
actions []func(Registrar)
rule *Rule
rules []*Rule
}
func (p *builtPlugin) Name() string { return p.name }
func (p *builtPlugin) Version() string { return p.version }
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
func (p *builtPlugin) Install(r Registrar) error {
if p.rule != nil {
r.Restrict(p.rule)
for _, rule := range p.rules {
r.Restrict(rule)
}
for _, action := range p.actions {
action(r)

View File

@@ -17,7 +17,8 @@ type recorder struct {
observers int
wrappers int
lifecycles int
rule *platform.Rule
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
@@ -25,7 +26,39 @@ func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Ob
}
func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule }
func (r *recorder) Restrict(rule *platform.Rule) {
r.rule = rule
r.rules = append(r.rules, rule)
}
// Restrict must snapshot each rule: a caller that reuses and mutates the
// same *Rule object across two Restrict calls must still get two distinct
// rules at Install time, not two pointers to the last mutation.
func TestBuilder_restrictClonesEachRule(t *testing.T) {
shared := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead}
b := platform.NewPlugin("p", "0").Restrict(shared)
// Reuse and mutate the same object, then register it again.
shared.Name = "im-rw"
shared.Allow[0] = "im/**"
shared.MaxRisk = platform.RiskWrite
p, err := b.Restrict(shared).Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
r := &recorder{}
if err := p.Install(r); err != nil {
t.Fatalf("Install: %v", err)
}
if len(r.rules) != 2 {
t.Fatalf("got %d rules, want 2", len(r.rules))
}
if r.rules[0].Name != "docs-ro" || r.rules[0].Allow[0] != "docs/**" || r.rules[0].MaxRisk != platform.RiskRead {
t.Errorf("rule[0] leaked later mutation: %+v", r.rules[0])
}
if r.rules[1].Name != "im-rw" || r.rules[1].Allow[0] != "im/**" {
t.Errorf("rule[1] = %+v, want im-rw / im/**", r.rules[1])
}
}
func TestBuilder_basicAssembly(t *testing.T) {
p, err := platform.NewPlugin("audit", "0.1.0").

View File

@@ -13,9 +13,10 @@ package platform
// identifier is "{plugin}.{hook}". A plugin cannot register two hooks
// with the same name in the same Install call.
//
// Restrict may be called at most once per plugin; multiple plugins
// contributing Restrict() is a configuration error (the resolver
// aborts startup).
// Restrict may be called multiple times per plugin; each call adds one
// scoped Rule (OR-combined by the engine). Two or more DISTINCT plugins
// contributing Restrict() is a configuration error (the resolver aborts
// startup).
type Registrar interface {
// Observe registers a side-effect-only command hook at the given
// When stage. The selector decides which commands it fires on.
@@ -29,8 +30,9 @@ type Registrar interface {
// On registers a lifecycle handler for the given event.
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
// Restrict contributes a pruning Rule. The framework merges it
// with the yaml-sourced Rule using single-rule semantics: plugin
// rule wins, but two plugins both calling Restrict abort startup.
// Restrict contributes a pruning Rule. May be called more than once
// to declare several scoped grants (OR-combined by the engine).
// Plugin rules take precedence over the yaml source; two distinct
// plugins both calling Restrict abort startup.
Restrict(r *Rule)
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()
}
// RoundTrip implements http.RoundTripper.

View File

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

View File

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

View File

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

View File

@@ -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 "<nil>"
}
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())
}
}

View File

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

View File

@@ -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 <identity>"),
// 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)
}
}

View File

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

View File

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

View File

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

View File

@@ -15,8 +15,12 @@ import (
// it hide?".
//
// Set once at bootstrap time; consumed read-only thereafter.
//
// Rules is the full set the winning source contributed (one rule for the
// common single-rule case, several when a plugin or yaml declares scoped
// grants). nil/empty means "no rule applied".
type ActivePolicy struct {
Rule *platform.Rule
Rules []*platform.Rule
Source ResolveSource
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
}
@@ -56,20 +60,26 @@ func GetActive() *ActivePolicy {
return cloneActivePolicy(activePolicy)
}
// cloneActivePolicy deep-copies the top-level struct plus the embedded
// Rule's slice fields. Other fields (Source, DeniedPaths) are value
// types so the struct copy already disjoints them.
// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
// value types so the struct copy already disjoints them.
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
if in == nil {
return nil
}
cp := *in
if in.Rule != nil {
rule := *in.Rule
rule.Allow = append([]string(nil), in.Rule.Allow...)
rule.Deny = append([]string(nil), in.Rule.Deny...)
rule.Identities = append([]platform.Identity(nil), in.Rule.Identities...)
cp.Rule = &rule
if in.Rules != nil {
cp.Rules = make([]*platform.Rule, len(in.Rules))
for i, r := range in.Rules {
if r == nil {
continue
}
rule := *r
rule.Allow = append([]string(nil), r.Allow...)
rule.Deny = append([]string(nil), r.Deny...)
rule.Identities = append([]platform.Identity(nil), r.Identities...)
cp.Rules[i] = &rule
}
}
return &cp
}

View File

@@ -17,6 +17,7 @@ package cmdpolicy
import (
"fmt"
"strings"
"github.com/bmatcuk/doublestar/v4"
"github.com/spf13/cobra"
@@ -36,16 +37,45 @@ type Decision struct {
Reason string // human-readable
}
// Engine evaluates a Rule against the command tree. It is stateless except
// for the Rule snapshot it was constructed with.
// Engine evaluates a set of Rules against the command tree with OR
// semantics: a command is allowed when it satisfies every axis of AT
// LEAST ONE rule. It is stateless except for the Rule snapshot it was
// constructed with.
type Engine struct {
rule *platform.Rule
rules []*platform.Rule
}
// New returns an Engine bound to a Rule. A nil Rule means "no user-layer
// restriction" -- EvaluateOne always returns Allowed=true.
// New returns an Engine bound to a single Rule. A nil Rule means "no
// user-layer restriction" -- EvaluateOne always returns Allowed=true.
// It is the ergonomic single-rule constructor, kept so existing callers
// (and the single-rule decision path) stay byte-for-byte unchanged.
func New(rule *platform.Rule) *Engine {
return &Engine{rule: rule}
if rule == nil {
return &Engine{}
}
return &Engine{rules: []*platform.Rule{rule}}
}
// NewSet returns an Engine bound to a set of Rules evaluated with OR
// semantics. An empty/nil slice means "no user-layer restriction". nil
// entries are dropped so callers may pass a slice with gaps without a
// separate filter step.
//
// With exactly one rule the behaviour is identical to New(rule): the
// rejection Decision is returned verbatim. With multiple rules a command
// rejected by all of them gets the aggregate reason_code
// "no_matching_rule" (see mergeDenials).
func NewSet(rules []*platform.Rule) *Engine {
cleaned := make([]*platform.Rule, 0, len(rules))
for _, r := range rules {
if r != nil {
cleaned = append(cleaned, r)
}
}
if len(cleaned) == 0 {
return &Engine{}
}
return &Engine{rules: cleaned}
}
// EvaluateAll walks the command tree and evaluates every **runnable**
@@ -81,27 +111,29 @@ func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision {
}
// EvaluateOne returns the user-layer decision for a single command. Always
// Allowed=true when the engine has no Rule.
// Allowed=true when the engine has no Rule. With multiple rules the
// decision is the OR over per-rule evaluations: the command is allowed as
// soon as one rule grants it; if every rule rejects it, the rejections are
// merged (see mergeDenials).
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
if e.rule == nil {
if len(e.rules) == 0 {
return Decision{Allowed: true}
}
r := e.rule
path := CanonicalPath(cmd)
if IsDiagnosticPath(path) {
return Decision{Allowed: true}
}
// A registered Rule expresses intent over the closed risk taxonomy
// (read / write / high-risk-write). Two ways a command can fall
// outside that taxonomy:
// risk_invalid is a property of the COMMAND's own annotation (the
// annotation exists but is a typo / not in the closed taxonomy
// read / write / high-risk-write). It is independent of any Rule and
// is always fail-closed regardless of AllowUnannotated -- a typo is a
// code bug, not a migration phase. So it is checked once up front,
// before the per-rule OR loop, and short-circuits to deny.
//
// - "absent" (no risk_level annotation) — fail-closed by default,
// but Rule.AllowUnannotated=true opts out for gradual adoption.
// - "invalid" (annotation exists but is a typo / not in the
// closed enum) — always fail-closed regardless of
// AllowUnannotated. Typo is a code bug, not a migration phase.
// The "absent" case (no risk_level annotation at all) is per-rule:
// each rule's AllowUnannotated decides, so it lives inside evalRule.
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
cmdRisk := platform.Risk(cmdRiskStr)
var (
@@ -117,7 +149,31 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)),
}
}
} else if !r.AllowUnannotated {
}
// OR across rules: the first rule that fully grants the command wins.
denials := make([]Decision, 0, len(e.rules))
for _, r := range e.rules {
d := evalRule(r, path, cmd, hasRisk, cmdRisk, cmdRank, cmdRankOk)
if d.Allowed {
return Decision{Allowed: true}
}
denials = append(denials, d)
}
return mergeDenials(e.rules, denials)
}
// evalRule applies one Rule's four-axis AND filter to a command whose
// risk annotation has already been parsed by EvaluateOne (risk_invalid is
// handled there). cmdRankOk is false only when the command is unannotated
// (hasRisk=false); a present-but-invalid risk never reaches here. Returns
// Allowed=true only when the command clears every axis of this rule.
func evalRule(r *platform.Rule, path string, cmd *cobra.Command, hasRisk bool, cmdRisk platform.Risk, cmdRank int, cmdRankOk bool) Decision {
// Unannotated gate: fail-closed unless THIS rule opts out. A command
// with no risk_level annotation can still be granted by a rule that
// sets AllowUnannotated=true (gradual-adoption opt-in); other rules in
// the set reject it here and the OR moves on.
if !hasRisk && !r.AllowUnannotated {
return Decision{
Allowed: false,
ReasonCode: "risk_not_annotated",
@@ -125,7 +181,9 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
}
}
// Axis 1: Deny has priority.
// Axis 1: Deny has priority. Note OR semantics scope a rule's Deny to
// that rule only -- it cannot veto another rule's Allow. A command to
// block everywhere must be denied (or simply not allowed) by every rule.
if matched, ok := firstMatch(r.Deny, path); ok {
return Decision{
Allowed: false,
@@ -171,6 +229,34 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
return Decision{Allowed: true}
}
// mergeDenials collapses the per-rule rejections into a single Decision
// for a command that no rule granted. denials is parallel to rules (same
// order, one entry per rule, all Allowed=false).
//
// With exactly one rule the original rejection is returned verbatim, so
// single-rule envelopes are byte-for-byte identical to the pre-multi-rule
// behaviour (reason_code / reason unchanged). With multiple rules the
// rejection is the aggregate reason_code "no_matching_rule"; its Reason
// enumerates each rule's own rejection for debugging.
func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
if len(denials) == 1 {
return denials[0]
}
parts := make([]string, len(denials))
for i, d := range denials {
name := rules[i].Name
if name == "" {
name = fmt.Sprintf("#%d", i)
}
parts[i] = fmt.Sprintf("%s: %s", name, d.ReasonCode)
}
return Decision{
Allowed: false,
ReasonCode: "no_matching_rule",
Reason: fmt.Sprintf("no rule grants this command (%s)", strings.Join(parts, "; ")),
}
}
// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed
// by canonical path. It performs the parent-group aggregation defined in
// the tech doc: a non-runnable parent whose every runnable descendant is

View File

@@ -398,6 +398,93 @@ func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) {
}
}
// --- Multi-rule (OR) semantics ---
// Two scoped rules (docs read-only, im writable) are OR-combined: a
// command is allowed when it satisfies ANY rule. This is the headline
// multi-rule use case -- different command groups need different risk
// ceilings within one policy.
func TestEvaluate_multiRuleOR(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
})
got := e.EvaluateAll(root)
// docs/+fetch (read) clears docs-ro.
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should be allowed by docs-ro")
}
// im/+send (write) clears im-rw even though docs-ro rejects it.
if !got["im/+send"].Allowed {
t.Errorf("im/+send (write) should be allowed by im-rw")
}
// docs/+update (write) exceeds docs-ro's read ceiling AND is outside
// im-rw's allow list -> rejected by both -> no_matching_rule.
if got["docs/+update"].Allowed {
t.Fatalf("docs/+update should be denied: read-only in docs, not allowed in im")
}
if rc := got["docs/+update"].ReasonCode; rc != "no_matching_rule" {
t.Errorf("docs/+update ReasonCode = %q, want no_matching_rule", rc)
}
}
// Identity can differ per rule: docs limited to user, im open to bot.
// This is the second half of the requirement -- some commands restrict
// identity, others allow the bot identity.
func TestEvaluate_multiRulePerRuleIdentity(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Name: "docs-user", Allow: []string{"docs/**"}, MaxRisk: "write", Identities: []platform.Identity{"user"}},
{Name: "im-bot", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"bot"}},
})
got := e.EvaluateAll(root)
// docs/+update identities=[user] -> docs-user grants.
if !got["docs/+update"].Allowed {
t.Errorf("docs/+update (user) should be allowed by docs-user")
}
// im/+send identities=[bot] -> im-bot grants.
if !got["im/+send"].Allowed {
t.Errorf("im/+send (bot) should be allowed by im-bot")
}
// docs/+delete-doc is high-risk-write -> exceeds both ceilings -> denied.
if got["docs/+delete-doc"].Allowed {
t.Errorf("docs/+delete-doc (high-risk-write) should be denied by both rules")
}
}
// NewSet with a single rule must behave exactly like New: the per-rule
// rejection (not the aggregate no_matching_rule) is preserved so the
// single-rule envelope is unchanged.
func TestEvaluate_newSetSingleRuleKeepsReason(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Allow: []string{"docs/**"}},
})
got := e.EvaluateAll(root)
if got["im/+send"].Allowed {
t.Fatalf("im/+send should be denied by docs-only rule")
}
if rc := got["im/+send"].ReasonCode; rc != "domain_not_allowed" {
t.Errorf("single-rule reason must be preserved verbatim, got %q want domain_not_allowed", rc)
}
}
// NewSet drops nil entries; an all-nil/empty set means "no restriction".
func TestNewSet_emptyAndNilMeansNoRestriction(t *testing.T) {
root := buildTree()
for _, rules := range [][]*platform.Rule{nil, {}, {nil}} {
got := cmdpolicy.NewSet(rules).EvaluateAll(root)
for path, d := range got {
if !d.Allowed {
t.Fatalf("empty/nil rule set must allow all, got deny for %s", path)
}
}
}
}
// Apply must install denyStubs only on Layer="policy" entries. A
// "strict_mode" denial in the same map must be left for
// applyStrictModeDenials in cmd/.

View File

@@ -33,44 +33,69 @@ type PluginRule struct {
type Sources struct {
PluginRules []PluginRule
YAMLRule *platform.Rule
YAMLRules []*platform.Rule
YAMLPath string
}
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one plugin may own the policy")
// Resolve picks by precedence: plugin > yaml > none. Pure function; load
// yaml via LoadYAMLPolicy first. Winner is validated.
func Resolve(s Sources) (*platform.Rule, ResolveSource, error) {
if len(s.PluginRules) > 1 {
names := make([]string, len(s.PluginRules))
for i, pr := range s.PluginRules {
names[i] = pr.PluginName
}
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names)
// Resolve picks by precedence: plugin > yaml > none, returning the full
// rule set the winning source contributes. Pure function; load yaml via
// LoadYAMLPolicy first. Every returned rule is validated.
//
// Multi-rule semantics (single owner): one plugin may contribute several
// rules (each a scoped grant, OR-combined by the engine), but two or more
// DISTINCT plugins contributing rules is still a configuration error --
// the resolver aborts so independent plugins cannot silently widen each
// other's policy. yaml may likewise carry several rules under "rules:".
func Resolve(s Sources) ([]*platform.Rule, ResolveSource, error) {
owners := distinctOwners(s.PluginRules)
if len(owners) > 1 {
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, owners)
}
if len(s.PluginRules) == 1 {
pr := s.PluginRules[0]
if err := ValidateRule(pr.Rule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
if len(s.PluginRules) > 0 {
rules := make([]*platform.Rule, 0, len(s.PluginRules))
for _, pr := range s.PluginRules {
if err := ValidateRule(pr.Rule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
}
rules = append(rules, pr.Rule)
}
return pr.Rule, ResolveSource{Kind: SourcePlugin, Name: pr.PluginName}, nil
return rules, ResolveSource{Kind: SourcePlugin, Name: owners[0]}, nil
}
if s.YAMLRule != nil {
if err := ValidateRule(s.YAMLRule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
if len(s.YAMLRules) > 0 {
for _, r := range s.YAMLRules {
if err := ValidateRule(r); err != nil {
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
}
}
return s.YAMLRule, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
return s.YAMLRules, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
}
return nil, ResolveSource{Kind: SourceNone}, nil
}
// distinctOwners returns the unique plugin names contributing a rule, in
// first-seen order. A single plugin contributing N rules collapses to one
// owner; that is the case the single-owner check below permits.
func distinctOwners(prs []PluginRule) []string {
seen := map[string]bool{}
owners := make([]string, 0, len(prs))
for _, pr := range prs {
if !seen[pr.PluginName] {
seen[pr.PluginName] = true
owners = append(owners, pr.PluginName)
}
}
return owners
}
// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent,
// so callers can pass the result straight into Sources.YAMLRule.
func LoadYAMLPolicy(path string) (*platform.Rule, error) {
// so callers can pass the result straight into Sources.YAMLRules. A
// present file yields one or more rules (see yaml.Parse).
func LoadYAMLPolicy(path string) ([]*platform.Rule, error) {
if path == "" {
return nil, nil
}
@@ -84,9 +109,9 @@ func LoadYAMLPolicy(path string) (*platform.Rule, error) {
if err != nil {
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
}
rule, err := pyaml.Parse(data)
rules, err := pyaml.Parse(data)
if err != nil {
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
}
return rule, nil
return rules, nil
}

View File

@@ -21,23 +21,45 @@ func TestResolve_singlePluginWins(t *testing.T) {
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
if len(got) != 1 || got[0] != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
t.Fatalf("Resolve = (%v, %+v)", got, src)
}
}
// A single plugin may contribute several rules (each a scoped grant). They
// are all returned, in registration order, under one plugin source.
func TestResolve_singlePluginMultipleRules(t *testing.T) {
r1 := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"}
r2 := &platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{
{PluginName: "secaudit", Rule: r1},
{PluginName: "secaudit", Rule: r2},
},
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 2 || got[0] != r1 || got[1] != r2 {
t.Fatalf("expected both rules in order, got %v", got)
}
if src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
t.Fatalf("source = %+v, want plugin:secaudit", src)
}
}
func TestResolve_pluginShadowsYaml(t *testing.T) {
pluginRule := &platform.Rule{Name: "from-plugin"}
yamlRule := &platform.Rule{Name: "from-yaml"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
YAMLRule: yamlRule,
YAMLRules: []*platform.Rule{yamlRule},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got.Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
if len(got) != 1 || got[0].Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src)
}
}
@@ -45,13 +67,13 @@ func TestResolve_pluginShadowsYaml(t *testing.T) {
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
YAMLRule: yamlRule,
YAMLPath: "/some/policy.yml",
YAMLRules: []*platform.Rule{yamlRule},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
if len(got) != 1 || got[0].Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
}
if src.Name != "/some/policy.yml" {
@@ -59,19 +81,36 @@ func TestResolve_yamlWhenNoPlugin(t *testing.T) {
}
}
// yaml may also carry several rules under "rules:"; all are returned.
func TestResolve_yamlMultipleRules(t *testing.T) {
r1 := &platform.Rule{Name: "a", MaxRisk: "read"}
r2 := &platform.Rule{Name: "b", MaxRisk: "write"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
YAMLRules: []*platform.Rule{r1, r2},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 2 || src.Kind != cmdpolicy.SourceYAML {
t.Fatalf("expected both yaml rules, got %v / %+v", got, src)
}
}
func TestResolve_emptyEverythingIsNone(t *testing.T) {
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got != nil || src.Kind != cmdpolicy.SourceNone {
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
if len(got) != 0 || src.Kind != cmdpolicy.SourceNone {
t.Fatalf("expected (empty, SourceNone), got (%v, %+v)", got, src)
}
}
// Two plugins both contributing a Rule must produce the typed error so
// the bootstrap pipeline aborts (hard-constraint #7).
func TestResolve_multipleRestrictIsError(t *testing.T) {
// Two DISTINCT plugins both contributing a Rule must produce the typed
// error so the bootstrap pipeline aborts (single-owner invariant): one
// plugin cannot silently widen another plugin's policy.
func TestResolve_multipleRestrictPluginsIsError(t *testing.T) {
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
@@ -84,26 +123,26 @@ func TestResolve_multipleRestrictIsError(t *testing.T) {
}
// LoadYAMLPolicy: missing file returns (nil, nil) silently so callers
// can pass the result straight into Sources.YAMLRule without special-
// can pass the result straight into Sources.YAMLRules without special-
// casing not-exist.
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
rule, err := cmdpolicy.LoadYAMLPolicy(missing)
rules, err := cmdpolicy.LoadYAMLPolicy(missing)
if err != nil {
t.Fatalf("missing yaml should not error, got %v", err)
}
if rule != nil {
t.Fatalf("missing yaml should return nil rule, got %+v", rule)
if rules != nil {
t.Fatalf("missing yaml should return nil rules, got %+v", rules)
}
}
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
rule, err := cmdpolicy.LoadYAMLPolicy("")
rules, err := cmdpolicy.LoadYAMLPolicy("")
if err != nil {
t.Fatalf("empty path should not error, got %v", err)
}
if rule != nil {
t.Fatalf("empty path should return nil rule, got %+v", rule)
if rules != nil {
t.Fatalf("empty path should return nil rules, got %+v", rules)
}
}
@@ -113,11 +152,11 @@ func TestLoadYAMLPolicy_parsesValid(t *testing.T) {
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
rules, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
if err != nil {
t.Fatalf("LoadYAMLPolicy err: %v", err)
}
if rule == nil || rule.Name != "from-yaml" {
t.Fatalf("expected rule with name=from-yaml, got %+v", rule)
if len(rules) != 1 || rules[0].Name != "from-yaml" {
t.Fatalf("expected one rule with name=from-yaml, got %+v", rules)
}
}

View File

@@ -5,6 +5,7 @@ package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
@@ -20,9 +21,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := levenshtein(lowered, best)
bestDist := suggest.Levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := levenshtein(lowered, string(c)); d < bestDist {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
@@ -40,47 +41,3 @@ func toLower(s string) string {
}
return string(b)
}
// levenshtein computes the classic edit distance between two strings.
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
// makes raw performance irrelevant — clarity beats trickiness here.
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(
prev[j]+1, // deletion
curr[j-1]+1, // insertion
prev[j-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}

View File

@@ -29,23 +29,3 @@ func TestSuggestRisk(t *testing.T) {
}
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"wrtie", "write", 2},
{"kitten", "sitting", 3},
}
for _, c := range cases {
got := levenshtein(c.a, c.b)
if got != c.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

View File

@@ -1,10 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package yaml parses a Rule from yaml bytes. It is kept separate from the
// public extension/platform package so that platform stays free of yaml
// library dependencies -- plugins constructing a Rule in Go code never
// import yaml, only the file loader does.
// Package yaml parses one or more Rules from yaml bytes. It is kept
// separate from the public extension/platform package so that platform
// stays free of yaml library dependencies -- plugins constructing a Rule
// in Go code never import yaml, only the file loader does.
//
// This package does **structural** parsing only (yaml syntax + unknown-field
// rejection). Semantic validation (valid MaxRisk enum, valid identity
@@ -23,9 +23,9 @@ import (
"github.com/larksuite/cli/extension/platform"
)
// schema is the internal yaml-tagged shape. Mirrors platform.Rule but lives
// here so the public Rule has no yaml tag baggage.
type schema struct {
// ruleSchema is the internal yaml-tagged shape of one rule. Mirrors
// platform.Rule but lives here so the public Rule has no yaml tag baggage.
type ruleSchema struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Allow []string `yaml:"allow,omitempty"`
@@ -35,35 +35,45 @@ type schema struct {
AllowUnannotated bool `yaml:"allow_unannotated,omitempty"`
}
// Parse decodes yaml bytes into a *platform.Rule. Unknown fields are
// rejected so an old binary cannot silently ignore new schema additions
// (forward-compat safeguard).
// fileSchema is the top-level document shape. Two mutually-exclusive
// layouts are accepted:
//
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
// the caller's responsibility -- run the result through
// internal/cmdpolicy.ValidateRule before handing it to the engine.
func Parse(data []byte) (*platform.Rule, error) {
var s schema
dec := gopkgyaml.NewDecoder(bytesReader(data))
dec.KnownFields(true)
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
// - a single rule written with flat top-level fields (the historical
// layout; the inlined ruleSchema), or
// - a "rules:" list of rule objects (multi-rule layout).
//
// Mixing the two (flat fields AND a rules: list in the same file) is a
// configuration error -- Parse rejects it rather than guessing intent.
//
// Rules is a pointer so Parse can tell "rules: key absent" (nil) apart
// from "rules: present but empty" (non-nil, len 0). The latter is a
// foot-gun -- a config generator that renders an empty list would
// otherwise yield a single all-zero Rule that lets every annotated
// command through -- so Parse rejects it outright.
type fileSchema struct {
ruleSchema `yaml:",inline"`
Rules *[]ruleSchema `yaml:"rules,omitempty"`
}
// Reject multi-document input: yaml.v3 only decodes one document
// per call, so a stray "---" followed by another document would
// silently drop the trailing rule.
var extra schema
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed")
// isZero reports whether every field is its zero value. Used to detect
// the flat-fields-plus-rules: mixing error.
func (s ruleSchema) isZero() bool {
return s.Name == "" && s.Description == "" &&
len(s.Allow) == 0 && len(s.Deny) == 0 &&
s.MaxRisk == "" && len(s.Identities) == 0 && !s.AllowUnannotated
}
func (s ruleSchema) toRule() *platform.Rule {
// Leave Identities nil when absent (omitempty-style), matching how the
// Allow/Deny slices arrive nil from yaml. A zero-length but non-nil
// slice is behaviourally identical to the engine but trips
// reflect.DeepEqual in tests and reads as "explicitly empty".
var idents []platform.Identity
if len(s.Identities) > 0 {
idents = make([]platform.Identity, len(s.Identities))
for i, id := range s.Identities {
idents[i] = platform.Identity(id)
}
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
idents := make([]platform.Identity, len(s.Identities))
for i, id := range s.Identities {
idents[i] = platform.Identity(id)
}
return &platform.Rule{
Name: s.Name,
@@ -73,5 +83,53 @@ func Parse(data []byte) (*platform.Rule, error) {
MaxRisk: platform.Risk(s.MaxRisk),
Identities: idents,
AllowUnannotated: s.AllowUnannotated,
}, nil
}
}
// Parse decodes yaml bytes into one or more *platform.Rule. Unknown fields
// are rejected so an old binary cannot silently ignore new schema additions
// (forward-compat safeguard).
//
// The result always has at least one element: a flat-fields document
// yields a single rule (possibly an all-zero "no restriction" rule), and a
// "rules:" list yields one rule per entry.
//
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
// the caller's responsibility -- run each result through
// internal/cmdpolicy.ValidateRule before handing it to the engine.
func Parse(data []byte) ([]*platform.Rule, error) {
var s fileSchema
dec := gopkgyaml.NewDecoder(bytesReader(data))
dec.KnownFields(true)
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
// Reject multi-document input: yaml.v3 only decodes one document
// per call, so a stray "---" followed by another document would
// silently drop the trailing rule.
var extra fileSchema
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed")
}
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
if s.Rules != nil {
if len(*s.Rules) == 0 {
return nil, fmt.Errorf("parse policy yaml: 'rules:' is present but empty; remove the key, or list at least one rule")
}
if !s.ruleSchema.isZero() {
return nil, fmt.Errorf("parse policy yaml: top-level rule fields cannot be combined with a 'rules:' list; move every rule under 'rules:'")
}
out := make([]*platform.Rule, 0, len(*s.Rules))
for _, rs := range *s.Rules {
out = append(out, rs.toRule())
}
return out, nil
}
// Backward-compatible single top-level rule (flat fields).
return []*platform.Rule{s.ruleSchema.toRule()}, nil
}

View File

@@ -24,7 +24,7 @@ max_risk: read
identities:
- user
`)
rule, err := pyaml.Parse(data)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
@@ -36,8 +36,59 @@ identities:
MaxRisk: "read",
Identities: []platform.Identity{"user"},
}
if !reflect.DeepEqual(rule, want) {
t.Fatalf("rule = %+v, want %+v", rule, want)
// A flat top-level rule yields exactly one element (backward compat).
if !reflect.DeepEqual(rules, []*platform.Rule{want}) {
t.Fatalf("rules = %+v, want single %+v", rules, want)
}
}
// A "rules:" list yields one platform.Rule per entry, in order. This is
// the multi-rule layout: each rule is a scoped grant the engine
// OR-combines.
func TestParse_rulesList(t *testing.T) {
data := []byte(`
rules:
- name: docs-ro
allow: [docs/**]
max_risk: read
- name: im-rw
allow: [im/**]
max_risk: write
identities: [user, bot]
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
want := []*platform.Rule{
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"user", "bot"}},
}
if !reflect.DeepEqual(rules, want) {
t.Fatalf("rules = %+v, want %+v", rules, want)
}
}
// A "rules:" key that is present but empty is a foot-gun: an empty list
// would otherwise fall through to a single all-zero Rule that allows
// every annotated command ("looks like a policy, enforces almost
// nothing"). Parse must reject it outright instead.
func TestParse_rejectsEmptyRulesList(t *testing.T) {
if _, err := pyaml.Parse([]byte("rules: []\n")); err == nil {
t.Fatalf("Parse should reject a present-but-empty 'rules:' list")
}
}
// Mixing top-level flat rule fields with a rules: list is ambiguous and
// must be rejected rather than silently picking one.
func TestParse_rejectsFlatPlusRulesMix(t *testing.T) {
data := []byte(`
name: top-level
rules:
- name: nested
`)
if _, err := pyaml.Parse(data); err == nil {
t.Fatalf("Parse should reject mixing top-level fields with a rules: list")
}
}
@@ -52,15 +103,15 @@ name: agent-readonly
max_risk: read
allow_unannotated: true
`)
rule, err := pyaml.Parse(data)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if !rule.AllowUnannotated {
if !rules[0].AllowUnannotated {
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
}
if rule.MaxRisk != "read" || rule.Name != "agent-readonly" {
t.Errorf("other fields lost: %+v", rule)
if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" {
t.Errorf("other fields lost: %+v", rules[0])
}
}
@@ -71,11 +122,11 @@ func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) {
name: x
max_risk: read
`)
rule, err := pyaml.Parse(data)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if rule.AllowUnannotated {
if rules[0].AllowUnannotated {
t.Fatalf("AllowUnannotated must default to false when key is absent")
}
}
@@ -96,12 +147,12 @@ mystery_field: oh no
// structural yaml; an invalid max_risk passes through (validation happens
// downstream).
func TestParse_doesNotValidateSemantics(t *testing.T) {
rule, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
rules, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
if err != nil {
t.Fatalf("structural parse should succeed, got %v", err)
}
if rule.MaxRisk != "nuclear" {
t.Fatalf("MaxRisk = %q, want passed through as-is", rule.MaxRisk)
if rules[0].MaxRisk != "nuclear" {
t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].MaxRisk)
}
}

View File

@@ -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.")
}

View File

@@ -23,7 +23,7 @@ import (
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
@@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
util.WarnIfProxied(f.IOStreams.ErrOut)
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
var transport http.RoundTripper = util.SharedTransport()
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
transport = wrapWithExtension(transport)
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
client := &http.Client{
Transport: transport,
Transport: rt,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
util.WarnIfProxied(f.IOStreams.ErrOut)
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = util.SharedTransport()
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}

View File

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

View File

@@ -41,7 +41,7 @@ const (
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 256
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -75,6 +75,8 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
h.Set("x-tt-env", "ppe_moa_canvas")
h.Set("x-use-ppe", "1")
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}

View File

@@ -9,7 +9,7 @@ import (
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()
}
func (t *RetryTransport) delay() time.Duration {
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
return transport.Fallback().RoundTrip(req)
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
@@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
return transport.Fallback().RoundTrip(req)
}
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()
}
// RoundTrip implements http.RoundTripper.

View File

@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through
// util.FallbackTransport rather than panicking. This covers the fallback
// transport.Fallback rather than panicking. This covers the fallback
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string

View File

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

View File

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

View File

@@ -20,4 +20,8 @@ const (
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
CliCAPath = "LARKSUITE_CLI_CA_PATH"
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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