Compare commits

...

61 Commits

Author SHA1 Message Date
zhaoyukun.yk
3bb68c317a feat(auth): support managed external credentials
Add Extended-only direct, credential-proxy, and platform-proxy modes while keeping Standard behavior and external-credential product details isolated behind source-neutral runtime capabilities.

Fail closed across helper execution, proxy routing, streams, managed file transfers, and unsupported event flows. Harden system configuration, edition updates, and release publication.

Cover Standard compatibility and Extended integration with contract tests, repository-local three-mode E2E, native trust checks, and CI and release gates.
2026-07-30 18:33:25 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
calendar-assistant
1f565a290b docs(calendar): warn against container-default timezone in time conversion (#2104)
Agents dropping to the raw `calendar events create/patch` API must convert
wall-clock time to Unix timestamps themselves. In UTC containers this silently
yields an 8-hour offset. Require explicit ISO 8601 offsets on +create/+update
--start/--end, and warn that raw-API timestamp conversion must specify the
target timezone instead of relying on the container default.
2026-07-30 14:06:14 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's &#32;/&#9;/&#13;/&#10; whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
367 changed files with 29117 additions and 1237 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,7 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -82,6 +82,56 @@ jobs:
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
extended-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build both editions and verify identity
run: |
set -euo pipefail
go build -o /tmp/lark-cli-standard .
go build -tags extended -o /tmp/lark-cli-extended .
test "$(/tmp/lark-cli-standard version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-extended version --json | jq -r '.capabilities[]')" = "external-credential-platform"
- name: Cross-compile Extended platform-specific security code
run: |
set -euo pipefail
GOOS=darwin GOARCH=arm64 go build -tags extended -o /tmp/lark-cli-extended-darwin .
GOOS=windows GOARCH=amd64 go build -tags extended -o /tmp/lark-cli-extended-windows.exe .
- name: Verify edition source isolation
run: go test -count=1 ./internal/externalcredential -run '^TestEditionSourceIsolation$'
- name: Run Extended tests
run: make extended-test
extended-platform-security:
needs: fast-gate
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run native helper isolation and path trust tests
run: go test -tags extended -count=1 ./internal/externalcredential -run '^(TestNativeAdminControlledPath|TestCredentialProcessEnvironmentUsesExplicitAllowlist|TestCredentialProcessCommandRunsWithIsolatedEnvironment)$'
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -142,6 +192,28 @@ jobs:
node-version: '22'
- name: Run script tests
run: make script-test
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
install-only: true
- name: Validate GoReleaser configuration
run: goreleaser check
- name: Check Extended installer syntax
shell: pwsh
run: |
sh -n scripts/install-extended.sh
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path scripts/install-extended.ps1),
[ref]$tokens,
[ref]$errors
) | Out-Null
if ($errors.Count -ne 0) {
$errors | ForEach-Object { Write-Error $_ }
exit 1
}
deterministic-gate:
needs: fast-gate
@@ -216,16 +288,20 @@ jobs:
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
go test -race -coverprofile=coverage-standard.txt -covermode=atomic $packages
# Extended implementation files are selected by build tags and would
# otherwise be absent from the uploaded report. Their race-enabled
# suite runs in extended-integration; this pass contributes coverage.
go test -tags extended -coverprofile=coverage-extended.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
files: coverage-standard.txt,coverage-extended.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
@@ -235,21 +311,31 @@ jobs:
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
report_coverage() {
profile="$1"
label="$2"
echo "### ${label} edition" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ ! -f "$profile" ]; then
echo "No ${label} coverage data available." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
return
fi
total=$(go tool cover -func="$profile" | grep total | awk '{print $3}')
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func="$profile" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
}
report_coverage coverage-standard.txt Standard
report_coverage coverage-extended.txt Extended
deadcode:
needs: fast-gate
@@ -520,7 +606,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration, extended-integration, extended-platform-security]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -542,6 +628,8 @@ jobs:
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-integration | ${{ needs.extended-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-platform-security | ${{ needs.extended-platform-security.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
@@ -565,7 +653,9 @@ jobs:
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
"${{ needs.license-header.result }}" \
"${{ needs.extended-integration.result }}" \
"${{ needs.extended-platform-security.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi

View File

@@ -9,10 +9,45 @@ permissions:
contents: read
jobs:
goreleaser:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -26,35 +61,176 @@ jobs:
with:
python-version: '3.x'
- name: Run GoReleaser
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Build and upload draft release with GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
cp scripts/install-extended.sh scripts/install-extended.ps1 dist/
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Verify release edition identities
run: |
set -euo pipefail
mkdir -p /tmp/lark-cli-standard /tmp/lark-cli-extended
tar -xzf "dist/lark-cli-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-standard lark-cli
tar -xzf "dist/lark-cli-extended-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-extended lark-cli
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
- name: Verify release platform asset matrix
run: bash scripts/verify-release-assets.sh dist "${GITHUB_REF_NAME#v}"
- name: Attest release archives
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/install-extended.sh
dist/install-extended.ps1
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt \
dist/install-extended.sh dist/install-extended.ps1 \
npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
- name: Publish verified GitHub release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
RELEASE_TAG: ${{ github.ref_name }}
with:
github-token: ${{ github.token }}
script: |
const crypto = require("node:crypto");
const fs = require("node:fs");
const tag = process.env.RELEASE_TAG;
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const matches = releases.filter((release) => release.tag_name === tag);
if (matches.length !== 1) {
throw new Error(`expected exactly one draft release for ${tag}, found ${matches.length}`);
}
const release = matches[0];
if (!release.draft) {
throw new Error(`release ${tag} became public before verification completed`);
}
const checksumPath = "dist/checksums.txt";
const checksumBody = fs.readFileSync(checksumPath, "utf8");
const expectedDigests = new Map();
for (const line of checksumBody.split(/\r?\n/)) {
if (!line.trim()) continue;
const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (!match) throw new Error(`invalid checksums.txt line: ${line}`);
const name = match[2];
if (expectedDigests.has(name)) {
throw new Error(`duplicate checksums.txt entry: ${name}`);
}
expectedDigests.set(name, `sha256:${match[1].toLowerCase()}`);
}
expectedDigests.set(
"checksums.txt",
`sha256:${crypto.createHash("sha256").update(checksumBody).digest("hex")}`,
);
const actualNames = release.assets.map((asset) => asset.name).sort();
const expectedNames = [...expectedDigests.keys()].sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
throw new Error(
`draft release asset set mismatch: expected ${expectedNames.join(", ")}, got ${actualNames.join(", ")}`,
);
}
for (const asset of release.assets) {
const expected = expectedDigests.get(asset.name);
if (!asset.digest) {
throw new Error(`GitHub did not report a digest for draft asset ${asset.name}`);
}
if (asset.digest.toLowerCase() !== expected) {
throw new Error(
`draft asset digest mismatch for ${asset.name}: expected ${expected}, got ${asset.digest}`,
);
}
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: false,
make_latest: "true",
});
publish-npm:
needs: goreleaser
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '20'
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -5,7 +5,8 @@ before:
- python3 scripts/fetch_meta.py
builds:
- binary: lark-cli
- id: standard
binary: lark-cli
env:
- CGO_ENABLED=0
ldflags:
@@ -18,12 +19,54 @@ builds:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
- id: extended
binary: lark-cli
tags:
- extended
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
- id: standard
ids:
- standard
name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats:
- zip
files:
- README.md
- LICENSE
- CHANGELOG.md
- id: extended
ids:
- extended
name_template: "lark-cli-extended-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats:
- zip
files:
- README.md
- LICENSE
@@ -31,6 +74,18 @@ archives:
checksum:
name_template: checksums.txt
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
release:
# Keep assets undiscoverable by releases/latest until the workflow has
# independently verified checksums, edition identity, and platform coverage.
draft: true
replace_existing_draft: true
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
changelog:
sort: asc

View File

@@ -2,6 +2,120 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
@@ -1608,6 +1722,11 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test extended-test
all: test
@@ -50,8 +50,9 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/release-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
@@ -121,6 +122,12 @@ sidecar-test:
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# extended-test compiles and exercises the separately distributed Extended
# edition. The default build remains the Standard npm/npx binary.
extended-test:
go build -tags extended -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags extended ./cmd/... ./internal/... ./shortcuts/... ./extension/... ./tests/externalcredential_e2e
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -30,21 +31,23 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
login := NewCmdAuthLogin(f, nil)
logout := NewCmdAuthLogout(f, nil)
status := NewCmdAuthStatus(f, nil)
scopes := NewCmdAuthScopes(f, nil)
list := NewCmdAuthList(f, nil)
check := NewCmdAuthCheck(f, nil)
qrcode := NewCmdAuthQRCode(f, nil)
for _, diagnostic := range []*cobra.Command{status, scopes, check, qrcode} {
cmdutil.SetRuntimeCapabilities(diagnostic)
}
cmd.AddCommand(login, logout, status, scopes, list, check, qrcode)
return cmd
}

View File

@@ -530,10 +530,7 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
}{
{"login", []string{"login"}},
{"logout", []string{"logout"}},
{"status", []string{"status"}},
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
{"list", []string{"list"}},
{"scopes", []string{"scopes"}},
}
for _, tt := range tests {
@@ -558,3 +555,19 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
})
}
}
func TestAuthReadOnlyCommandsAllowedByExternalProvider(t *testing.T) {
f := newFactoryWithExternalProvider(t)
for _, name := range []string{"status", "check", "scopes", "qrcode"} {
t.Run(name, func(t *testing.T) {
cmd := NewCmdAuth(f)
matched, _, err := cmd.Find([]string{name})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("read-only command blocked: %v", err)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"context"
"fmt"
"strings"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
)
@@ -33,7 +35,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
if runF != nil {
return runF(opts)
}
return authCheckRun(opts)
return authCheckRunContext(cmd.Context(), opts)
},
}
@@ -46,6 +48,10 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
func authCheckRun(opts *CheckOptions) error {
return authCheckRunContext(context.Background(), opts)
}
func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
f := opts.Factory
required := strings.Fields(opts.Scope)
@@ -57,18 +63,74 @@ func authCheckRun(opts *CheckOptions) error {
if err != nil {
return err
}
if config.UserOpenId == "" {
if f.Credential == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "credential inspection is unavailable")
}
inspection, err := f.Credential.InspectToken(ctx, credential.TokenInspectionRequest{
TokenSpec: credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: config.AppID,
},
IncludeScopes: true,
})
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"failed to inspect user authorization: %v", err).
WithCause(err)
}
if inspection == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source returned no authorization inspection")
}
if inspection.Status == credential.TokenInspectionNotLoggedIn && !inspection.Source.Managed {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
return output.ErrBare(1)
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
if !inspection.Present {
if inspection.Source.Managed {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"credential source %q did not provide a user access token", inspection.Source.Name).
WithHint("authorize the user through the selected credential source")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
return output.ErrBare(1)
}
missing := larkauth.MissingScopes(stored.Scope, required)
switch inspection.ScopeState {
case credential.ScopeUnsupported:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check is unsupported by credential source %q because granted scopes are unavailable", inspection.Source.Name).
WithHint("the credential source must expose trusted scope metadata before `auth check` can evaluate --scope")
case credential.ScopeUnknown:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check result is unknown because credential source %q returned no scope metadata", inspection.Source.Name).
WithHint("configure the credential source to return trusted scopes for user access tokens")
case credential.ScopeKnown:
// Continue below.
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source %q returned invalid scope inspection state %q", inspection.Source.Name, inspection.ScopeState)
}
suggestion := ""
missing := larkauth.MissingScopes(inspection.Scopes, required)
if inspection.Source.Managed {
if len(missing) > 0 {
suggestion = fmt.Sprintf("grant these scopes through credential source %s: %s", inspection.Source.Name, strings.Join(missing, " "))
}
} else {
suggestion = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
return writeAuthCheckResult(f, required, inspection.Scopes, suggestion)
}
func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes, suggestion string) error {
missing := larkauth.MissingScopes(availableScopes, required)
missingSet := make(map[string]bool, len(missing))
for _, s := range missing {
missingSet[s] = true
@@ -82,8 +144,8 @@ func authCheckRun(opts *CheckOptions) error {
ok := len(missing) == 0
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
if len(missing) > 0 {
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
if len(missing) > 0 && suggestion != "" {
result["suggestion"] = suggestion
}
output.PrintJson(f.IOStreams.Out, result)
if !ok {

View File

@@ -4,14 +4,20 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -146,6 +152,128 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
}
}
type authCheckExternalProvider struct {
token *extcred.Token
capabilities credential.ProviderCapabilities
resolveCalls int
}
func (p *authCheckExternalProvider) Name() string { return "external-check-test" }
func (p *authCheckExternalProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app", Brand: extcred.BrandFeishu}, nil
}
func (p *authCheckExternalProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
p.resolveCalls++
return p.token, nil
}
func (p *authCheckExternalProvider) CredentialCapabilities() credential.ProviderCapabilities {
return p.capabilities
}
func externalAuthCheckFactory(t *testing.T, canInspectScopes bool, token *extcred.Token) (*cmdutil.Factory, *authCheckExternalProvider) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app",
Brand: core.BrandFeishu,
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
provider := &authCheckExternalProvider{
token: token,
capabilities: credential.ProviderCapabilities{
ProvidesOnDemandAuth: true,
CanInspectScopes: canInspectScopes,
},
}
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{provider},
nil,
nil,
nil,
)
return f, provider
}
func TestAuthCheckRun_ExternalDirectUsesProviderScopes(t *testing.T) {
f, provider := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
Scopes: "im:message docx:document",
})
stdout := f.IOStreams.Out.(*bytes.Buffer)
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
if err != nil {
t.Fatalf("authCheckRun() error = %v", err)
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveToken calls = %d, want 1", provider.resolveCalls)
}
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.Fatalf("stdout.ok = %v, want true; payload=%v", payload["ok"], payload)
}
granted, ok := payload["granted"].([]any)
if !ok || len(granted) != 1 || granted[0] != "im:message" {
t.Fatalf("stdout.granted = %v, want [im:message]", payload["granted"])
}
}
func TestAuthCheckRun_ExternalProxyReturnsTypedUnknown(t *testing.T) {
f, provider := externalAuthCheckFactory(t, false, &extcred.Token{
Value: "proxy-placeholder",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unsupported") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unsupported result with actionable hint and no param", problem)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveToken calls = %d, want 0 for proxy scope check", provider.resolveCalls)
}
}
func TestAuthCheckRun_ExternalDirectWithoutScopeMetadataReturnsTypedUnknown(t *testing.T) {
f, _ := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unknown") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unknown result with actionable hint and no param", problem)
}
}
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

View File

@@ -56,6 +56,7 @@ For ASCII output, the result is printed to stdout with fixed size.`,
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -44,6 +44,10 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
func authStatusRun(opts *StatusOptions) error {
f := opts.Factory
editionStatus, err := inspectEditionStatus(f)
if err != nil {
return err
}
config, err := f.Config()
if err != nil {
@@ -64,7 +68,9 @@ func authStatusRun(opts *StatusOptions) error {
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
if !applyEditionStatus(result, diagnostics, editionStatus) {
addStatusNote(result, diagnostics)
}
output.PrintJson(f.IOStreams.Out, result)
return nil

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"context"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct {
provider string
variant string
}
func inspectEditionStatus(f *cmdutil.Factory) (editionStatusState, error) {
if f == nil || f.Credential == nil {
return editionStatusState{}, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return editionStatusState{}, err
}
if source == nil || !source.Managed {
return editionStatusState{}, nil
}
state := editionStatusState{provider: source.Name}
description := f.RuntimeDescription()
if description.Managed {
state.variant = description.Variant
}
return state, nil
}
func applyEditionStatus(result map[string]interface{}, diagnostics identitydiag.Result, state editionStatusState) bool {
if state.provider == "" {
return false
}
result["source"] = "external"
result["credentialProvider"] = state.provider
if state.variant != "" {
result["externalCredentialMode"] = state.variant
}
switch {
case !diagnostics.User.Available && diagnostics.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(diagnostics.User.Status) +
"; bot identity is ready. Update authorization through external credential provider " + state.provider + "."
case diagnostics.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh. Check external credential provider " + state.provider + "."
case !diagnostics.User.Available && !diagnostics.Bot.Available:
result["note"] = "No usable identity is available. Check external credential provider " + state.provider + "."
}
return true
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedAuthStatusReportsManagedSource(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
},
}))
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["source"] != "external" ||
got["credentialProvider"] != "env" ||
got["externalCredentialMode"] != "managed-test" ||
got["identity"] != "bot" {
t.Fatalf("output = %#v", got)
}
if note, _ := got["note"].(string); strings.Contains(note, "auth login") ||
!strings.Contains(note, "external credential provider env") {
t.Fatalf("note = %q", note)
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct{}
func inspectEditionStatus(*cmdutil.Factory) (editionStatusState, error) {
return editionStatusState{}, nil
}
func applyEditionStatus(map[string]interface{}, identitydiag.Result, editionStatusState) bool {
return false
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardAuthStatusPreservesExistingProjection(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := got[field]; exists {
t.Fatalf("Standard auth status contains edition field %q: %s", field, stdout.String())
}
}
var note string
if err := json.Unmarshal(got["note"], &note); err != nil {
t.Fatal(err)
}
if !strings.Contains(note, "lark-cli auth login") {
t.Fatalf("Standard note = %q, want established login guidance", note)
}
}

View File

@@ -35,6 +35,15 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
t.Fatalf("json.Unmarshal(raw) error = %v", err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := raw[field]; exists {
t.Fatalf("local auth status unexpectedly contains edition field %q: %s", field, stdout.String())
}
}
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {

View File

@@ -29,6 +29,7 @@ import (
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -45,6 +46,7 @@ type buildConfig struct {
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
runtime *runtimebootstrap.Result
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -58,6 +60,14 @@ func WithStartupBrand(brand core.LarkBrand) BuildOption {
}
}
// withRuntimeBootstrap shares one invocation snapshot across registry,
// credentials, transports, and command capabilities.
func withRuntimeBootstrap(runtime *runtimebootstrap.Result) BuildOption {
return func(c *buildConfig) {
c.runtime = runtime
}
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
@@ -143,9 +153,9 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
// buildInternal assembles the command tree from one immutable startup
// configuration snapshot. Profile selection happens before any registry
// network decision and the same result is passed to the Factory.
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
@@ -168,13 +178,29 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
startup := cfg.runtime
if startup == nil {
startup = runtimebootstrap.Resolve(inv.Profile)
}
f := cmdutil.NewDefault(cfg.streams, inv)
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
// Runtime policy can close direct metadata egress before any command is
// registered, without exposing a concrete credential mode here.
registryBrand := cfg.startupBrand
if registryBrand == "" {
registryBrand = resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
}
if !startup.Plan.AllowsRemoteMetadata() {
if registryBrand == "" {
registryBrand = core.BrandFeishu
}
registry.InitEmbeddedWithBrand(registryBrand)
} else if registryBrand != "" {
registry.InitWithBrand(registryBrand)
}
f := cmdutil.NewDefaultWithRuntimePlan(cfg.streams, inv, startup.ProfileConfig, startup.Plan)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
@@ -220,6 +246,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
registerEditionCommands(rootCmd, f)
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
if !cfg.skipService {

176
cmd/build_workspace_test.go Normal file
View File

@@ -0,0 +1,176 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
func TestStartupProfileSnapshotUsesDetectedWorkspace(t *testing.T) {
previousWorkspace := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
tests := []struct {
name string
workspace core.Workspace
signalName string
signalValue string
expectedAppID string
expectedBrand core.LarkBrand
}{
{
name: "local",
workspace: core.WorkspaceLocal,
expectedAppID: "cli_local",
expectedBrand: core.BrandFeishu,
},
{
name: "openclaw",
workspace: core.WorkspaceOpenClaw,
signalName: "OPENCLAW_CLI",
signalValue: "1",
expectedAppID: "cli_openclaw",
expectedBrand: core.BrandLark,
},
{
name: "hermes",
workspace: core.WorkspaceHermes,
signalName: "HERMES_HOME",
signalValue: "/managed/hermes",
expectedAppID: "cli_hermes",
expectedBrand: core.BrandLark,
},
{
name: "lark_channel",
workspace: core.WorkspaceLarkChannel,
signalName: "LARK_CHANNEL",
signalValue: "1",
expectedAppID: "cli_lark_channel",
expectedBrand: core.BrandLark,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configRoot := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configRoot)
t.Setenv(envvars.CliExternalCredentialConfig,
filepath.Join(configRoot, "missing-external-credential.json"))
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
if tt.signalName != "" {
t.Setenv(tt.signalName, tt.signalValue)
}
writeWorkspaceProfile(t, core.WorkspaceLocal, "local", "cli_local", core.BrandFeishu)
if !tt.workspace.IsLocal() {
writeWorkspaceProfile(t, tt.workspace, tt.name, tt.expectedAppID, tt.expectedBrand)
}
// Execute resolves the registry brand before entering
// buildInternal. Pin that ordering independently.
core.SetCurrentWorkspace(core.WorkspaceLocal)
if got := selectInvocationWorkspace(); got != tt.workspace {
t.Fatalf("selected workspace = %q, want %q", got, tt.workspace)
}
if got := ResolveStartupBrand(""); got != tt.expectedBrand {
t.Fatalf("startup brand = %q, want %q", got, tt.expectedBrand)
}
// Build/buildInternal is also a public construction path. Reset the
// process state to local so the test proves it establishes the
// workspace before SelectProfile captures the immutable snapshot.
core.SetCurrentWorkspace(core.WorkspaceLocal)
factory, _, _ := buildInternal(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), io.Discard, io.Discard),
WithoutPlugins(),
WithoutServiceCommands(),
)
if got := core.CurrentWorkspace(); got != tt.workspace {
t.Fatalf("workspace after build = %q, want %q", got, tt.workspace)
}
config, err := factory.Config()
if err != nil {
t.Fatalf("Factory.Config() error = %v", err)
}
if config.AppID != tt.expectedAppID || config.Brand != tt.expectedBrand {
t.Fatalf("resolved config = app %q (%s), want app %q (%s)",
config.AppID, config.Brand, tt.expectedAppID, tt.expectedBrand)
}
})
}
}
func clearWorkspaceSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
"OPENCLAW_CLI",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_SERVICE_MARKER",
"OPENCLAW_SERVICE_VERSION",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_SHELL",
"HERMES_HOME",
"HERMES_QUIET",
"HERMES_EXEC_ASK",
"HERMES_GATEWAY_TOKEN",
"HERMES_SESSION_KEY",
"LARK_CHANNEL",
} {
t.Setenv(name, "")
}
}
func clearCredentialSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
envvars.CliAppID,
envvars.CliAppSecret,
envvars.CliBrand,
envvars.CliUserAccessToken,
envvars.CliTenantAccessToken,
envvars.CliDefaultAs,
envvars.CliStrictMode,
} {
t.Setenv(name, "")
}
}
func writeWorkspaceProfile(
t *testing.T,
workspace core.Workspace,
name string,
appID string,
brand core.LarkBrand,
) {
t.Helper()
previous := core.CurrentWorkspace()
core.SetCurrentWorkspace(workspace)
defer core.SetCurrentWorkspace(previous)
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: name,
Apps: []core.AppConfig{{
Name: name,
AppId: appID,
AppSecret: core.PlainSecret("test-secret-" + name),
Brand: brand,
Users: []core.AppUser{},
}},
}); err != nil {
t.Fatalf("save %s workspace profile: %v", workspace.Display(), err)
}
}

View File

@@ -6,6 +6,7 @@ package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/spf13/cobra"
)
@@ -19,21 +20,38 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
initCmd := NewCmdConfigInit(f, nil)
bind := NewCmdConfigBind(f, nil)
remove := NewCmdConfigRemove(f, nil)
show := NewCmdConfigShow(f, nil)
defaultAs := NewCmdConfigDefaultAs(f)
strictMode := NewCmdConfigStrictMode(f)
riskControl := NewCmdConfigRiskControl(f)
policy := NewCmdConfigPolicy(f)
plugins := NewCmdConfigPlugins(f)
keychainDowngrade := NewCmdConfigKeychainDowngrade(f)
// Identity preferences live in the Profile, but external providers have
// historically treated these config commands as credential management.
// Check Profile ownership first so a managed runtime gives the actionable
// deployment-managed Profile error, then retain the credential capability
// so Standard external-provider behavior stays unchanged.
for _, identitySetting := range []*cobra.Command{defaultAs, strictMode} {
cmdutil.SetRuntimeCapabilities(
identitySetting,
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
)
}
for _, sourceNeutral := range []*cobra.Command{show, riskControl, policy, plugins} {
cmdutil.SetRuntimeCapabilities(sourceNeutral)
}
cmd.AddCommand(initCmd, bind, remove, show, defaultAs, strictMode, riskControl, policy, plugins, keychainDowngrade)
return cmd
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimeplan"
)
type noopConfigKeychain struct{}
@@ -452,10 +453,16 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
}
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
type stubConfigExtProvider struct{ name string }
type stubConfigExtProvider struct {
name string
err error
}
func (s *stubConfigExtProvider) Name() string { return s.name }
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
if s.err != nil {
return nil, s.err
}
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
@@ -481,7 +488,6 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
@@ -509,6 +515,63 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}
}
func TestConfigIdentityCommandsCheckProfileOwnershipBeforeCredentialOwnership(t *testing.T) {
profileDenied := errors.New("Profile identity settings are deployment-managed")
credentialChecks := 0
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
switch capability {
case runtimeplan.CapabilityLocalProfileMutation:
return profileDenied
case runtimeplan.CapabilityLocalCredentialManagement:
credentialChecks++
}
return nil
},
})
for _, args := range [][]string{
{"default-as", "bot"},
{"strict-mode", "bot"},
} {
t.Run(args[0], func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdConfig(f)
cmd.SetArgs(args)
err := cmd.Execute()
if !errors.Is(err, profileDenied) {
t.Fatalf("Execute(%v) error = %v, want Profile ownership denial", args, err)
}
})
}
if credentialChecks != 0 {
t.Fatalf("credential capability checked %d times after Profile denial, want 0", credentialChecks)
}
}
func TestConfigIdentityCommandsRetainCredentialOwnershipCapability(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := NewCmdConfig(f)
for _, name := range []string{"default-as", "strict-mode"} {
t.Run(name, func(t *testing.T) {
leaf, _, err := root.Find([]string{name})
if err != nil {
t.Fatal(err)
}
got := cmdutil.GetRuntimeCapabilities(leaf)
want := []runtimeplan.Capability{
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("%s capabilities = %v, want %v", name, got, want)
}
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.

View File

@@ -27,13 +27,6 @@ func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
Use: "plugins",
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
Short: "Inspect installed plugins and their hook contributions",
// Same leaf-level no-op as config policy: the parent `config`
// group's PersistentPreRunE requires builtin credential, but
// this is a read-only diagnostic that must work everywhere.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPluginsShow(f))
return cmd

View File

@@ -16,12 +16,6 @@ func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
Use: "policy",
Hidden: true,
Short: "Inspect the user-layer command policy",
// Override parent's RequireBuiltinCredentialProvider check; this
// group is read-only diagnostic and must work under any provider.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPolicyShow(f))
return cmd

View File

@@ -132,19 +132,16 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
}
}
// Regression: the parent `config` command declares a PersistentPreRunE
// that calls RequireBuiltinCredentialProvider; env credentials cause
// it to return external_provider. `config policy` is a diagnostic
// group that must not be blocked by that check. The group declares
// its own no-op PersistentPreRunE so cobra's "first walking up from
// leaf" picks ours over the config parent's.
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
// The policy group explicitly overrides the config parent's local credential
// management capability because it is source-neutral diagnostics.
func TestConfigPolicyOverridesCredentialManagementCapability(t *testing.T) {
f, _, _ := newPolicyTestFactory()
group := NewCmdConfigPolicy(f)
if group.PersistentPreRunE == nil {
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
root := NewCmdConfig(f)
leaf, _, err := root.Find([]string{"policy", "show"})
if err != nil {
t.Fatal(err)
}
if err := group.PersistentPreRunE(group, nil); err != nil {
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
if capabilities := cmdutil.GetRuntimeCapabilities(leaf); len(capabilities) != 0 {
t.Fatalf("policy capabilities = %v, want source-neutral", capabilities)
}
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -42,6 +42,16 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
// config show describes the effective invocation configuration, not merely
// the bytes in config.json. Preserve the typed bootstrap failure so a
// Standard binary cannot present a local Profile as active when the system
// requires Extended runtime support.
if startupErr := f.RuntimeStartupError(); startupErr != nil {
return startupErr
}
if handled, editionErr := showEditionConfig(f); handled {
return editionErr
}
config, err := core.LoadMultiAppConfig()
if err != nil {

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
type editionConfigShowResult struct {
Source string `json:"source"`
CredentialProvider string `json:"credentialProvider"`
Manageable bool `json:"manageable"`
Workspace string `json:"workspace"`
AppID string `json:"appId"`
Brand string `json:"brand"`
DefaultAs string `json:"defaultAs"`
Profile *string `json:"profile,omitempty"`
ExternalCredentialMode *string `json:"externalCredentialMode,omitempty"`
RemoteEndpoint *string `json:"remoteEndpoint,omitempty"`
}
func showEditionConfig(f *cmdutil.Factory) (bool, error) {
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return true, typedEditionProviderError("determine the active credential provider", err)
}
if source == nil || !source.Managed {
return false, nil
}
if source.AppID == "" {
return true, errs.NewInternalError(errs.SubtypeInvalidResponse,
"external credential provider %q returned no account", source.Name)
}
result := editionConfigShowResult{
Source: "external",
CredentialProvider: source.Name,
Manageable: false,
Workspace: core.CurrentWorkspace().Display(),
AppID: source.AppID,
Brand: string(source.Brand),
DefaultAs: string(source.DefaultAs),
}
description := f.RuntimeDescription()
if source.ProfileName != "" {
result.Profile = &source.ProfileName
}
if description.Managed && description.Variant != "" {
result.ExternalCredentialMode = &description.Variant
if description.ProxiesRequests {
result.RemoteEndpoint = &description.DataPlaneEndpoint
}
}
output.PrintJson(f.IOStreams.Out, result)
return true, nil
}
func typedEditionProviderError(action string, err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s: %v", action, err).WithCause(err)
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedConfigShowAllowedWithManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
cmd := NewCmdConfig(f)
matched, _, err := cmd.Find([]string{"show"})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("config show blocked: %v", err)
}
}
func TestExtendedConfigShowProjectsManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
var stdout bytes.Buffer
f.IOStreams.Out = &stdout
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
ProxiesRequests: true,
DataPlaneEndpoint: "https://managed.example.test",
},
}))
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun() error = %v", err)
}
var got editionConfigShowResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Source != "external" ||
got.CredentialProvider != "env" ||
got.Manageable ||
got.AppID != "test-app" ||
got.ExternalCredentialMode == nil ||
*got.ExternalCredentialMode != "managed-test" ||
got.RemoteEndpoint == nil ||
*got.RemoteEndpoint != "https://managed.example.test" {
t.Fatalf("output = %#v", got)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &fields); err != nil {
t.Fatal(err)
}
if _, ok := fields["appSecret"]; ok {
t.Fatalf("managed output must not invent appSecret: %s", stdout.String())
}
if _, ok := fields["users"]; ok {
t.Fatalf("managed output must not invent users: %s", stdout.String())
}
}
func TestExtendedConfigShowTypesManagedSourceFailure(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
providerErr := errors.New("provider failed")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&stubConfigExtProvider{name: "broken", err: providerErr}},
nil, nil, nil,
)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("error = %#v, want internal/unknown", err)
}
if !errors.Is(err, providerErr) {
t.Fatalf("error does not preserve provider failure: %v", err)
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import "github.com/larksuite/cli/internal/cmdutil"
func showEditionConfig(*cmdutil.Factory) (bool, error) {
return false, nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestStandardConfigShowPreservesLocalConfigPath(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryConfig ||
problem.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("error = %#v, want established config/not_configured result", err)
}
}
func TestStandardConfigShowReturnsTypedRuntimeStartupError(t *testing.T) {
startupErr := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"system external credential configuration requires the lark-cli Extended edition",
).WithHint("install lark-cli Extended or ask the administrator to remove external-credential.json")
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(
t,
nil,
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly),
)
err := configShowRun(&ConfigShowOptions{Factory: f})
if !errors.Is(err, startupErr) {
t.Fatalf("config show error = %v, want original startup error", err)
}
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryValidation ||
problem.Subtype != errs.SubtypeFailedPrecondition ||
problem.Message != "system external credential configuration requires the lark-cli Extended edition" {
t.Fatalf("config show problem = %#v, want typed Extended-required startup failure", problem)
}
if stdout.Len() != 0 {
t.Fatalf("config show wrote local Profile after bootstrap failure: %s", stdout.String())
}
}

View File

@@ -84,6 +84,10 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, checkCLIUpdate()...)
}
if handled, editionErr := runEditionDoctor(opts, checks); handled {
return editionErr
}
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
@@ -130,8 +134,7 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
// the source-appropriate remediation. A command here would be redundant.
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
@@ -215,7 +218,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := update.FetchLatest()
latest, err := fetchLatestForEdition()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
)
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
f := opts.Factory
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(opts.Ctx)
if err != nil {
checks = append(checks, fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)))
return true, finishDoctor(f, checks)
}
if source == nil || !source.Managed {
return false, nil
}
provider := source.Name
cfg, err := f.Config()
if err != nil {
checks = append(checks,
fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)),
skip("config_file", fmt.Sprintf("local credentials are not used; source is %s", provider)),
)
return true, finishDoctor(f, checks)
}
checks = append(checks, pass("credential_source",
fmt.Sprintf("credentials provided by %s (app %s; token not verified by this check)", provider, cfg.AppID)))
description := f.RuntimeDescription()
if description.Managed {
checks = append(checks, pass("config_file", "config.json found (system external credential mode)"))
} else {
checks = append(checks, skip("config_file",
fmt.Sprintf("local config not used; credentials provided by %s", provider)))
}
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
)
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
if description.ProxiesRequests {
checks = append(checks, editionProxyNetworkCheck(opts, description.DataPlaneEndpoint, diagnostics))
} else {
checks = append(checks, networkChecks(opts.Ctx, opts, core.ResolveEndpoints(cfg.Brand))...)
}
return true, finishDoctor(f, checks)
}
func editionDiagnosticErrorHint(err error) string {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
return blockErr.Reason
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
return cfgErr.Hint
}
return ""
}
func editionProxyNetworkCheck(opts *DoctorOptions, endpoint string, diagnostics identitydiag.Result) checkResult {
if opts.Offline {
return skip("endpoint_external_platform", "skipped (--offline)")
}
verified := func(id identitydiag.Identity) bool { return id.Verified != nil && *id.Verified }
if verified(diagnostics.User) || verified(diagnostics.Bot) {
return pass("endpoint_external_platform", endpoint+" reachable through an authenticated API request")
}
return fail("endpoint_external_platform", endpoint+" could not complete an authenticated API request",
"check the external credential program and platform logs")
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"context"
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestExtendedProxyNetworkCheckUsesAuthenticatedDiagnostics(t *testing.T) {
verified := true
endpoint := "https://credentials.example.com"
got := editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{
User: identitydiag.Identity{Verified: &verified},
})
if got.Status != "pass" || got.Name != "endpoint_external_platform" {
t.Fatalf("check = %#v", got)
}
got = editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{})
if got.Status != "fail" {
t.Fatalf("unverified check = %#v, want fail", got)
}
}
func TestExtendedDoctorManagedSourceDoesNotRequireLocalConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
OK bool `json:"ok"`
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.OK {
t.Fatalf("checks = %#v", got.Checks)
}
assertCheck(t, got.Checks, "credential_source", "pass")
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "skip" ||
!strings.Contains(configCheck.Message, "local config") ||
strings.Contains(configCheck.Message, "config init") {
t.Fatalf("config_file = %#v", configCheck)
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/errs"
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
if opts == nil || opts.Factory == nil {
return false, nil
}
startupErr := opts.Factory.RuntimeStartupError()
if startupErr == nil {
return false, nil
}
hint := ""
if problem, ok := errs.ProblemOf(startupErr); ok {
hint = problem.Hint
}
checks = append(checks, fail("credential_source", startupErr.Error(), hint))
return true, finishDoctor(opts.Factory, checks)
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import (
"context"
"encoding/json"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardDoctorPreservesConfigFirstDiagnostics(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want established missing-config failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
assertCheck(t, got.Checks, "config_file", "fail")
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("Standard doctor exposed edition diagnostic: %#v", got.Checks)
}
}
}

View File

@@ -4,7 +4,6 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
@@ -175,6 +174,44 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
return nil, nil
}
type failingDefaultAccountResolver struct {
err error
}
func (r *failingDefaultAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return nil, r.err
}
func TestDoctor_DefaultResolutionFailurePreservesConfigFileCheck(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider(
nil,
&failingDefaultAccountResolver{err: core.NotConfiguredError()},
nil,
nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want not-configured failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "fail" {
t.Fatalf("config_file = %#v, want fail", configCheck)
}
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("default source resolution replaced the legacy config check: %#v", got.Checks)
}
}
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
@@ -195,12 +232,8 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = cred
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import "github.com/larksuite/cli/internal/extendedupdate"
func fetchLatestForEdition() (string, error) {
return extendedupdate.FetchLatest()
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/internal/update"
func fetchLatestForEdition() (string, error) {
return update.FetchLatest()
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
func TestStandardDoctorReportsEditionSentinelWithoutLocalProfile(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configDir := t.TempDir()
systemPath := filepath.Join(configDir, "external-credential.json")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
if err := vfs.WriteFile(systemPath, []byte("sentinel-only"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
root := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), &stdout, &stderr),
WithoutPlugins(),
WithoutServiceCommands(),
)
root.SetArgs([]string{"doctor", "--offline"})
if err := root.ExecuteContext(context.Background()); err == nil {
t.Fatal("doctor returned nil, want failed diagnostic result")
}
var report struct {
Checks []struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Hint string `json:"hint"`
} `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("decode doctor output: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
}
for _, check := range report.Checks {
if check.Name != "credential_source" {
continue
}
if check.Status != "fail" ||
check.Message != "system external credential configuration requires the lark-cli Extended edition" ||
!strings.Contains(check.Hint, "install lark-cli Extended") {
t.Fatalf("credential_source check = %#v", check)
}
if strings.Contains(stdout.String(), "config init") {
t.Fatalf("doctor suggested local credential bootstrap for an edition sentinel: %s", stdout.String())
}
return
}
t.Fatalf("doctor did not report the edition sentinel: %s", stdout.String())
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands owns the Extended-only command surface.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestExtendedRegistersVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Extended edition commands = %v, want [version]", commands)
}
if commands[0].Hidden {
t.Fatal("Extended version command must be visible")
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands keeps the release identity probe callable in
// Standard while cmd/version hides it from help. This preserves the ordinary
// command surface and gives installers/CI one edition-neutral verification
// contract.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestStandardRegistersHiddenVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Standard edition commands = %v, want [version]", commands)
}
if !commands[0].Hidden {
t.Fatal("Standard version command must remain hidden")
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
@@ -16,14 +17,31 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
// Without SilenceUsage, RunE errors print the full flag help banner.
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
// This hook shadows root's PersistentPreRun, so preserve the matched
// command for structured error and declared-scope hints.
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityRealtimeEvents)
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f))
consume := NewCmdConsume(f)
bus := NewCmdBus(f)
list := NewCmdList(f)
schema := NewCmdSchema(f)
status := NewCmdStatus(f)
stop := NewCmdStop(f)
for _, local := range []*cobra.Command{list, schema, status, stop} {
cmdutil.SetRuntimeCapabilities(local)
}
cmd.AddCommand(consume)
cmd.AddCommand(list)
cmd.AddCommand(schema)
cmd.AddCommand(status)
cmd.AddCommand(stop)
cmd.AddCommand(bus)
return cmd
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"io/fs"
"path/filepath"
"testing"
"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/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
func TestEventCommandsRejectDeniedRuntimeCapability(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_runtime_event_test",
AppSecret: "must-not-be-used",
Brand: core.BrandFeishu,
}
denied := errs.NewValidationError(errs.SubtypeFailedPrecondition,
"real-time events are unavailable in this runtime").
WithHint("use a runtime that supports real-time events")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityRealtimeEvents {
return denied
}
return nil
},
})
t.Run("consume", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"consume", "guarded-before-event-lookup"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
})
t.Run("bus", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"_bus"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
if _, err := vfs.Stat(filepath.Join(configDir, "events")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("event bus created runtime files before guard: %v", err)
}
})
}
func TestEventCommandRuntimeCapabilityMatrix(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdEvents(f)
parentCapabilities := cmdutil.GetRuntimeCapabilities(cmd)
if len(parentCapabilities) != 1 || parentCapabilities[0] != runtimeplan.CapabilityRealtimeEvents {
t.Fatalf("event capabilities = %v, want [%s]", parentCapabilities, runtimeplan.CapabilityRealtimeEvents)
}
wantRealtime := map[string]bool{
"_bus": true,
"consume": true,
"list": false,
"schema": false,
"status": false,
"stop": false,
}
children := make(map[string]*cobra.Command, len(wantRealtime))
for _, child := range cmd.Commands() {
name := child.Name()
want, ok := wantRealtime[name]
if !ok {
t.Fatalf("event command %q is missing from the runtime capability matrix", name)
}
children[name] = child
got := cmdutil.GetRuntimeCapabilities(child)
if want {
if len(got) != 1 || got[0] != runtimeplan.CapabilityRealtimeEvents {
t.Errorf("event %s capabilities = %v, want [%s]", name, got, runtimeplan.CapabilityRealtimeEvents)
}
continue
}
if len(got) != 0 {
t.Errorf("event %s capabilities = %v, want source-neutral local command", name, got)
}
}
if len(children) != len(wantRealtime) {
t.Fatalf("event command matrix covered %d commands, want %d", len(children), len(wantRealtime))
}
// Clearing the parent declaration must also clear both consumers. This
// proves they inherit the fail-closed default instead of duplicating a
// leaf annotation that future event commands could forget.
cmdutil.SetRuntimeCapabilities(cmd)
for _, name := range []string{"consume", "_bus"} {
if got := cmdutil.GetRuntimeCapabilities(children[name]); len(got) != 0 {
t.Errorf("event %s capabilities after clearing parent = %v, want inherited empty declaration", name, got)
}
}
}
func requireExternalEventGuard(t *testing.T, err error) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed problem", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want %s/%s",
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "" {
t.Fatalf("param = %q, want empty", validationErr.Param)
}
if problem.Hint == "" {
t.Fatal("hint is empty")
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdProfile creates the profile command with subcommands.
@@ -14,13 +15,26 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// A child PersistentPreRunE shadows root's PersistentPreRun, so retain
// the invocation state used by structured error hints here.
cmd.SilenceUsage = true
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalProfileMutation)
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
})
cmd.AddCommand(NewCmdProfileList(f))
list := NewCmdProfileList(f)
// Listing profiles is read-only and remains useful for diagnostics under a
// managed credential runtime. Every other profile subcommand mutates local
// profile selection, config, or keychain state and inherits the parent gate.
cmdutil.SetRuntimeCapabilities(list)
cmd.AddCommand(list)
cmd.AddCommand(NewCmdProfileUse(f))
cmd.AddCommand(NewCmdProfileAdd(f))
cmd.AddCommand(NewCmdProfileRemove(f))

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"bytes"
"context"
"errors"
"path/filepath"
"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"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
type recordingProfileKeychain struct {
gets int
sets int
removes int
}
func (k *recordingProfileKeychain) Get(_, _ string) (string, error) {
k.gets++
return "", nil
}
func (k *recordingProfileKeychain) Set(_, _, _ string) error {
k.sets++
return nil
}
func (k *recordingProfileKeychain) Remove(_, _ string) error {
k.removes++
return nil
}
func TestProfileMutationCommandsAreDeniedBeforeLocalStateChanges(t *testing.T) {
denied := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
).WithHint("manage credentials through the active provider")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return denied
}
return nil
},
})
tests := []struct {
name string
args []string
}{
{
name: "add",
args: []string{"add", "--name", "new", "--app-id", "app-new", "--app-secret-stdin"},
},
{
name: "use",
args: []string{"use", "target"},
},
{
name: "rename",
args: []string{"rename", "target", "renamed"},
},
{
name: "remove",
args: []string{"remove", "target"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configDir := setupProfileConfigDir(t)
saveManagedGateFixture(t)
configPath := filepath.Join(configDir, "config.json")
before, err := vfs.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(before) error = %v", err)
}
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
f.IOStreams.In = strings.NewReader("must-not-be-read\n")
keychain := &recordingProfileKeychain{}
f.Keychain = keychain
cmd := NewCmdProfile(f)
cmd.SetArgs(tt.args)
err = cmd.Execute()
if !errors.Is(err, denied) {
t.Fatalf("Execute() error = %v, want denied runtime error", err)
}
after, readErr := vfs.ReadFile(configPath)
if readErr != nil {
t.Fatalf("ReadFile(after) error = %v", readErr)
}
if !bytes.Equal(after, before) {
t.Fatalf("config changed despite runtime denial:\nbefore: %s\nafter: %s", before, after)
}
if keychain.gets != 0 || keychain.sets != 0 || keychain.removes != 0 {
t.Fatalf("keychain calls = get:%d set:%d remove:%d, want none",
keychain.gets, keychain.sets, keychain.removes)
}
})
}
}
func TestProfileListRemainsAvailableWhenLocalMutationIsDenied(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
)
}
return nil
},
})
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdProfile(f)
cmd.SetArgs([]string{"list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list was blocked by mutation capability: %v", err)
}
if !strings.Contains(stdout.String(), `"name": "default"`) {
t.Fatalf("profile list output = %s, want default profile", stdout.String())
}
}
func TestProfileMutationCommandsRemainAvailableByDefault(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
// origin/main allows Profile preparation while an environment/extension
// provider is active. The managed runtime blocks this through its explicit
// plan policy; generic provider ownership must not change Standard.
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{profileEnvironmentProvider{}},
nil,
nil,
nil,
)
cmd := NewCmdProfile(f)
args := []string{"use", "target"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use with default runtime plan error = %v", err)
}
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "target" || saved.PreviousApp != "default" {
t.Fatalf("selection = current:%q previous:%q, want target/default",
saved.CurrentApp, saved.PreviousApp)
}
}
type profileEnvironmentProvider struct{}
func (profileEnvironmentProvider) Name() string { return "env" }
func (profileEnvironmentProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{
AppID: "cli_environment",
Brand: extcred.BrandFeishu,
SupportedIdentities: extcred.SupportsAll,
}, nil
}
func (profileEnvironmentProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: "environment-token"}, nil
}
func saveManagedGateFixture(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.SecretInput{Ref: &core.SecretRef{
Source: "keychain",
ID: "appsecret:app-target",
}},
Brand: core.BrandLark,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -100,6 +101,12 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
// Resolve all startup state from the detected workspace. This must happen
// before ResolveStartupBrand, isSingleAppMode, or buildInternal reads
// workspace-scoped configuration.
selectInvocationWorkspace()
startup := runtimebootstrap.Resolve(inv.Profile)
startupBrand := resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
configureFlagCompletions(os.Args)
ctx := context.Background()
@@ -107,7 +114,8 @@ func Execute() int {
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
WithStartupBrand(startupBrand),
withRuntimeBootstrap(startup),
)
// --- Notices (non-blocking) ---
@@ -137,7 +145,7 @@ func Execute() int {
// or both may be present in any given envelope.
func setupNotices() {
// Binary update — synchronous cache check + async refresh
if info := update.CheckCached(build.Version); info != nil {
if info := checkCachedEditionUpdate(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
@@ -147,9 +155,9 @@ func setupNotices() {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
update.RefreshCache(ver)
refreshEditionUpdateCache(ver)
if update.GetPending() == nil {
if info := update.CheckCached(ver); info != nil {
if info := checkCachedEditionUpdate(ver); info != nil {
update.SetPending(info)
}
}

View File

@@ -18,6 +18,7 @@ import (
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -435,6 +436,25 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
}
}
func TestHandleRootError_BlockErrorPreservesUntypedFallback(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
blockErr := &extcred.BlockError{Provider: "env", Reason: "LARKSUITE_CLI_APP_ID is missing"}
exit := handleRootError(f, blockErr)
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if errObj["type"] != "internal" || errObj["subtype"] != "unknown" {
t.Fatalf("error = %#v", errObj)
}
if errObj["message"] != "blocked by env: LARKSUITE_CLI_APP_ID is missing" {
t.Fatalf("error.message = %v", errObj["message"])
}
if exit != int(output.ExitInternal) {
t.Fatalf("exit = %d, want %d", exit, output.ExitInternal)
}
}
// 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

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -58,10 +57,10 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
// Gate 4: cached newer version from this binary's release channel.
// Standard reads the npm-backed cache; Extended reads its separate
// GitHub-release cache.
info := checkCachedEditionUpdate(build.Version)
if info == nil {
return
}

View File

@@ -6,7 +6,6 @@ package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -15,13 +14,21 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
func updateStateFileForEdition(edition string) string {
if edition == "extended" {
return "update-state-extended.json"
}
return "update-state.json"
}
func writeUpdateState(t *testing.T, dir, edition, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
if err := vfs.WriteFile(filepath.Join(dir, updateStateFileForEdition(edition)), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
@@ -105,7 +112,7 @@ func TestOfferRootUpgrade(t *testing.T) {
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
writeUpdateState(t, dir, build.Edition, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
@@ -135,6 +142,53 @@ func TestOfferRootUpgrade(t *testing.T) {
}
}
func TestOfferRootUpgradeIgnoresOtherEditionCache(t *testing.T) {
origV := build.Version
build.Version = "1.0.0"
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
otherEdition := "extended"
if build.Edition == "extended" {
otherEdition = "standard"
}
writeUpdateState(t, dir, otherEdition, "9.0.0")
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: true,
OutIsTerminal: true,
StderrIsTerminal: true,
}}
offerRootUpgrade(f, &cobra.Command{})
if strings.Contains(errBuf.String(), "available") {
t.Fatalf("%s prompt consumed %s cache: %q", build.Edition, otherEdition, errBuf.String())
}
if called {
t.Fatalf("%s upgrade ran from %s cache", build.Edition, otherEdition)
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })

View File

@@ -10,17 +10,34 @@ import (
"github.com/larksuite/cli/internal/envvars"
)
// selectInvocationWorkspace establishes the workspace before any startup
// consumer reads workspace-scoped configuration. Execute needs this before
// resolving the registry brand, while Build/buildInternal needs it before
// capturing the immutable Profile snapshot passed to the Factory.
func selectInvocationWorkspace() core.Workspace {
workspace := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(workspace)
return workspace
}
// ResolveStartupBrand resolves the brand before the command tree is built, so
// the registry's remote metadata overlay uses the configured brand from the
// first catalog access. It mirrors the credential chain's brand precedence —
// environment, then the active profile's raw config entry — without touching
// the keychain (no secrets are needed to know the brand).
func ResolveStartupBrand(profile string) core.LarkBrand {
config, _ := core.LoadMultiAppConfig()
return resolveStartupBrandFromConfig(profile, config)
}
// resolveStartupBrandFromConfig keeps registry routing on the same immutable
// Profile snapshot used by credentials and runtime policy.
func resolveStartupBrandFromConfig(profile string, config *core.MultiAppConfig) core.LarkBrand {
if raw := os.Getenv(envvars.CliBrand); raw != "" {
return core.ParseBrand(raw)
}
if cfg, err := core.LoadMultiAppConfig(); err == nil {
if app := cfg.CurrentAppConfig(profile); app != nil {
if config != nil {
if app := config.CurrentAppConfig(profile); app != nil {
return core.ParseBrand(string(app.Brand))
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
)
var (
fetchExtendedLatest = extendedupdate.FetchLatest
installExtended = extendedupdate.Install
)
func updateLongDescription() string {
return `Update lark-cli Extended from the matching GitHub Release.
The command downloads the lark-cli-extended asset for the current platform,
verifies its SHA-256 checksum and compiled edition identity, then replaces the
current binary. It never installs the Standard npm/npx edition.
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}
func runEditionUpdate(opts *UpdateOptions) (bool, error) {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
if !opts.Check {
updater.Brand = resolveSkillsBrand(opts.Factory, io.ErrOut)
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
latest, err := fetchExtendedLatest()
if err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "network", typed)
}
return true, reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to check the latest Extended version: %v", err).WithCause(err))
}
if update.ParseVersion(latest) == nil {
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse,
"invalid Extended version from GitHub Releases: %s", latest))
}
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
}
return true, reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
if opts.Check {
return true, reportCheckResult(opts, io, cur, latest, true)
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli Extended %s %s %s from GitHub Releases ...\n", cur, symArrow(), latest)
}
if err := installExtended(latest); err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "update_error", typed)
}
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeUnknown,
"failed to install lark-cli Extended: %v", err).WithCause(err))
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "edition": build.Edition, "action": "updated",
"message": fmt.Sprintf("lark-cli Extended updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
applySkillsResult(result, skillsResult)
output.PrintJson(io.Out, result)
return true, nil
}
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli Extended from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
emitSkillsTextHints(io, skillsResult)
return true, nil
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
)
func TestExtendedUpdateUsesExtendedReleaseInstaller(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion, oldUpdater, oldSync := currentVersion, newUpdater, syncSkills
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion, newUpdater, syncSkills = oldVersion, oldUpdater, oldSync
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installed := ""
installExtended = func(version string) error {
installed = version
return nil
}
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallManual}
}}
}
syncSkills = func(skillscheck.SyncOptions) *skillscheck.SyncResult { return &skillscheck.SyncResult{} }
var out, errOut bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &errOut), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
if installed != "1.2.4" {
t.Fatalf("installed version = %q, want 1.2.4", installed)
}
var result map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["edition"] != "extended" || result["action"] != "updated" {
t.Fatalf("result = %#v", result)
}
}
func TestExtendedUpdateCheckDoesNotInstall(t *testing.T) {
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion := currentVersion
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion = oldVersion
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installExtended = func(string) error {
t.Fatal("installer called during --check")
return nil
}
var out bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &bytes.Buffer{}), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
func runEditionUpdate(*UpdateOptions) (bool, error) { return false, nil }
func updateLongDescription() string {
return `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}

View File

@@ -101,15 +101,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update lark-cli to the latest version",
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
Long: updateLongDescription(),
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
@@ -124,6 +116,9 @@ Use --check to only check for updates without installing.`,
}
func updateRun(opts *UpdateOptions) error {
if handled, err := runEditionUpdate(opts); handled {
return err
}
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
import (

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/update"
)
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return extendedupdate.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
extendedupdate.RefreshCache(currentVersion)
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import "github.com/larksuite/cli/internal/update"
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return update.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
update.RefreshCache(currentVersion)
}

56
cmd/version/version.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
type options struct {
factory *cmdutil.Factory
json bool
}
type versionReport struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
// NewCmdVersion reports the immutable edition identity compiled into the
// binary. Root --version remains unchanged for compatibility.
func NewCmdVersion(f *cmdutil.Factory) *cobra.Command {
opts := &options{factory: f}
cmd := &cobra.Command{
Use: "version",
Short: "Show version and edition information",
Hidden: hideVersionCommand(),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.json {
output.PrintJson(opts.factory.IOStreams.Out, versionReport{
Version: build.Version,
Edition: build.Edition,
Capabilities: build.Capabilities(),
})
return nil
}
_, err := fmt.Fprintf(opts.factory.IOStreams.Out, "lark-cli version %s (%s)\n", build.Version, build.Edition)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write version output: %v", err).WithCause(err)
}
return nil
},
}
cmd.Flags().BoolVar(&opts.json, "json", false, "structured JSON output")
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestVersionJSONReportsCompiledEdition(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
var got struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Version != build.Version || got.Edition != build.Edition || !reflect.DeepEqual(got.Capabilities, build.Capabilities()) {
t.Fatalf("version output = %#v, want version=%q edition=%q", got, build.Version, build.Edition)
}
}
func TestVersionVisibilityPreservesStandardHelpSurface(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
wantHidden := build.Edition == "standard"
if cmd.Hidden != wantHidden {
t.Fatalf("version command hidden = %v, want %v for %s", cmd.Hidden, wantHidden, build.Edition)
}
}
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestVersionTextWriteFailureIsTyped(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
writeErr := errors.New("write failed")
f.IOStreams.Out = failingWriter{err: writeErr}
cmd := NewCmdVersion(f)
err := cmd.ExecuteContext(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeSDKError {
t.Fatalf("error = %#v, want internal/sdk_error", err)
}
if !errors.Is(err, writeErr) {
t.Fatalf("error does not preserve write failure: %v", err)
}
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package version
func hideVersionCommand() bool { return false }

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package version
// Standard keeps its historical help surface unchanged. The command remains
// directly callable for release identity verification.
func hideVersionCommand() bool { return true }

View File

@@ -62,6 +62,8 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.message` | informational | not safe to branch on |
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.origin` | informational | Extended producer: `cli`, `credential_process`, `proxy`, or `lark`; omitted by Standard to preserve its existing envelope; consumers must tolerate absence and unknown future values |
| `error.proxy_request_id` | informational | external credential platform trace id; never stored in `log_id` |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
@@ -104,7 +106,7 @@ already succeeded).
| `config` | local config missing / unbound | 3 | `ConfigError` |
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `policy` | security policy denial/challenge, including content safety | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
@@ -272,7 +274,7 @@ legal for framework dynamic paths (e.g. classifier fanout) but the lint
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
| Transport or external dependency failure | `errs.NewNetworkError(subtype, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport` / `credential_source_unavailable` / `upstream_unavailable`) |
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
@@ -513,7 +515,11 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
typed struct as a documented extension field, not into the envelope's
top level.
top level. The external credential platform contract is the single explicit
exception: `origin` and `proxy_request_id` are shared across several error
categories and therefore live in `Problem`. Both fields are optional, and
consumers must ignore them when absent or unknown. Any further shared field
still requires an explicit contract revision and wire-format pin.
## CI guards

134
errs/diagnostic_metadata.go Normal file
View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
)
// DiagnosticMetadata carries optional producer diagnostics without changing
// the field layout of Problem or any concrete typed error. Keeping this
// metadata in a wrapper preserves source compatibility for callers that use
// positional literals of the existing exported error structs.
type DiagnosticMetadata struct {
Origin string
ProxyRequestID string
}
type diagnosticMetadataWrapper struct {
err error
typed error
metadata DiagnosticMetadata
}
func (e *diagnosticMetadataWrapper) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *diagnosticMetadataWrapper) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func (e *diagnosticMetadataWrapper) ProblemDetail() *Problem {
if e == nil {
return nil
}
problem, _ := ProblemOf(e.typed)
return problem
}
func (e *diagnosticMetadataWrapper) DiagnosticMetadata() DiagnosticMetadata {
if e == nil {
return DiagnosticMetadata{}
}
return e.metadata
}
// MarshalJSON preserves the concrete typed error's extension fields and adds
// the optional diagnostics as sibling fields in the existing error object.
func (e *diagnosticMetadataWrapper) MarshalJSON() ([]byte, error) {
raw, err := json.Marshal(e.typed)
if err != nil {
return nil, err
}
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if e.metadata.Origin != "" {
origin, err := json.Marshal(e.metadata.Origin)
if err != nil {
return nil, err
}
object["origin"] = origin
}
if e.metadata.ProxyRequestID != "" {
requestID, err := json.Marshal(e.metadata.ProxyRequestID)
if err != nil {
return nil, err
}
object["proxy_request_id"] = requestID
}
return json.Marshal(object)
}
// WithDiagnosticMetadata attaches optional wire diagnostics to a typed error.
// Empty metadata is a no-op. The returned wrapper still participates in
// errors.Is/errors.As and TypedError routing through Unwrap and ProblemDetail.
func WithDiagnosticMetadata(err error, metadata DiagnosticMetadata) error {
if err == nil || (metadata.Origin == "" && metadata.ProxyRequestID == "") {
return err
}
typed, ok := UnwrapTypedError(err)
if !ok {
return err
}
if existing, ok := diagnosticMetadataWrapperForProducer(typed); ok {
merged := existing.metadata
if metadata.Origin != "" {
merged.Origin = metadata.Origin
}
if metadata.ProxyRequestID != "" {
merged.ProxyRequestID = metadata.ProxyRequestID
}
return &diagnosticMetadataWrapper{err: err, typed: existing.typed, metadata: merged}
}
return &diagnosticMetadataWrapper{err: err, typed: typed, metadata: metadata}
}
// DiagnosticMetadataOf returns optional diagnostics attached to the first
// typed producer in err's wrap chain. Metadata on a typed cause belongs to
// that inner producer and must not be projected onto an outer typed error.
func DiagnosticMetadataOf(err error) (DiagnosticMetadata, bool) {
typed, ok := UnwrapTypedError(err)
if !ok {
return DiagnosticMetadata{}, false
}
carrier, ok := diagnosticMetadataWrapperForProducer(typed)
if !ok {
return DiagnosticMetadata{}, false
}
metadata := carrier.DiagnosticMetadata()
if metadata.Origin == "" && metadata.ProxyRequestID == "" {
return DiagnosticMetadata{}, false
}
return metadata, true
}
// diagnosticMetadataWrapperForProducer deliberately checks only the selected
// typed producer. errors.As must not be used here because it would traverse
// into an inner typed cause and associate that cause's metadata with the outer
// producer.
func diagnosticMetadataWrapperForProducer(typed error) (*diagnosticMetadataWrapper, bool) {
wrapper, ok := typed.(*diagnosticMetadataWrapper) //nolint:errorlint // Exact producer identity is the invariant being enforced.
return wrapper, ok
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"errors"
"fmt"
"testing"
)
func TestDiagnosticMetadataPreservesTypedErrorContract(t *testing.T) {
permission := NewPermissionError(SubtypeMissingScope, "missing scope").
WithMissingScopes("im:message")
wrapped := WithDiagnosticMetadata(permission, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_1",
})
var gotPermission *PermissionError
if !errors.As(wrapped, &gotPermission) || gotPermission != permission {
t.Fatalf("errors.As() = %p, want original permission error %p", gotPermission, permission)
}
problem, ok := ProblemOf(wrapped)
if !ok || problem != &permission.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want original Problem %p", problem, ok, &permission.Problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "proxy" || metadata.ProxyRequestID != "proxy_req_1" {
t.Fatalf("DiagnosticMetadataOf() = (%#v, %v)", metadata, ok)
}
raw, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
if object["origin"] != "proxy" || object["proxy_request_id"] != "proxy_req_1" {
t.Fatalf("metadata missing from JSON: %s", raw)
}
missingScopes, ok := object["missing_scopes"].([]any)
if !ok || len(missingScopes) != 1 || missingScopes[0] != "im:message" {
t.Fatalf("typed extension fields missing from JSON: %s", raw)
}
}
func TestDiagnosticMetadataMergesWithoutMutatingExistingWrapper(t *testing.T) {
typed := NewNetworkError(SubtypeUpstreamUnavailable, "unavailable")
withOrigin := WithDiagnosticMetadata(typed, DiagnosticMetadata{Origin: "proxy"})
withRequestID := WithDiagnosticMetadata(withOrigin, DiagnosticMetadata{ProxyRequestID: "proxy_req_2"})
original, _ := DiagnosticMetadataOf(withOrigin)
if original.ProxyRequestID != "" {
t.Fatalf("existing wrapper was mutated: %#v", original)
}
merged, ok := DiagnosticMetadataOf(withRequestID)
if !ok || merged.Origin != "proxy" || merged.ProxyRequestID != "proxy_req_2" {
t.Fatalf("merged metadata = (%#v, %v)", merged, ok)
}
}
func TestDiagnosticMetadataPreservesOuterErrorContext(t *testing.T) {
cause := errors.New("transport failed")
typed := NewNetworkError(SubtypeNetworkTransport, "request failed").WithCause(cause)
outer := fmt.Errorf("fetch document: %w", typed)
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "proxy"})
if got, want := wrapped.Error(), outer.Error(); got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
if !errors.Is(wrapped, cause) {
t.Fatal("metadata wrapper lost the original cause chain")
}
var gotTyped *NetworkError
if !errors.As(wrapped, &gotTyped) || gotTyped != typed {
t.Fatalf("errors.As() = %p, want original typed error %p", gotTyped, typed)
}
}
func TestDiagnosticMetadataDoesNotCrossTypedProducerBoundary(t *testing.T) {
inner := NewNetworkError(SubtypeUpstreamUnavailable, "proxy unavailable")
annotatedInner := WithDiagnosticMetadata(inner, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_inner",
})
outer := NewInternalError(SubtypeUnknown, "business reclassified failure").
WithCause(annotatedInner)
if metadata, ok := DiagnosticMetadataOf(outer); ok {
t.Fatalf("outer typed producer inherited inner metadata: %#v", metadata)
}
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "cli"})
problem, ok := ProblemOf(wrapped)
if !ok || problem != &outer.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want outer Problem %p", problem, ok, &outer.Problem)
}
if problem.Category != CategoryInternal ||
problem.Subtype != SubtypeUnknown ||
problem.Message != "business reclassified failure" {
t.Fatalf("outer typed identity changed: %#v", problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "cli" || metadata.ProxyRequestID != "" {
t.Fatalf("outer metadata = (%#v, %v), want cli without inner request id", metadata, ok)
}
innerMetadata, ok := DiagnosticMetadataOf(annotatedInner)
if !ok ||
innerMetadata.Origin != "proxy" ||
innerMetadata.ProxyRequestID != "proxy_req_inner" {
t.Fatalf("inner metadata was mutated: (%#v, %v)", innerMetadata, ok)
}
}

View File

@@ -27,7 +27,11 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
Identity: "user",
ConsoleURL: "https://example",
}
b, err := json.Marshal(pe)
withMetadata := WithDiagnosticMetadata(pe, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_123",
})
b, err := json.Marshal(withMetadata)
if err != nil {
t.Fatal(err)
}
@@ -39,6 +43,8 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
`"message":"x"`,
`"hint":"y"`,
`"log_id":"lg"`,
`"origin":"proxy"`,
`"proxy_request_id":"proxy_req_123"`,
`"missing_scopes":["docx:document"]`,
`"identity":"user"`,
`"console_url":"https://example"`,

View File

@@ -48,11 +48,13 @@ const (
// CategoryNetwork subtypes
const (
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
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
SubtypeCredentialSourceUnavailable Subtype = "credential_source_unavailable" // external credential program or identity service is temporarily unavailable
SubtypeUpstreamUnavailable Subtype = "upstream_unavailable" // external proxy cannot reach the requested upstream service
)
// CategoryAPI subtypes

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package build
const Edition = "extended"
func Capabilities() []string {
return []string{"external-credential-platform"}
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package build
const Edition = "standard"
func Capabilities() []string { return []string{} }

View File

@@ -23,6 +23,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/requestcontext"
"github.com/larksuite/cli/internal/util"
)
@@ -119,6 +120,7 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ctx = requestcontext.WithIdentity(ctx, as)
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
@@ -155,6 +157,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
ctx = requestcontext.WithIdentity(ctx, as)
cfg := buildConfig(opts)
// Resolve auth
@@ -212,6 +215,9 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"io"
"net/http"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
// RemoteFile is a service-returned file reference that has passed the active
// runtime's data-plane policy. Its URL is intentionally private so callers
// cannot construct a trusted reference without Validate.
type RemoteFile struct {
rawURL string
}
// URL returns the original service-provided URL verbatim.
func (f RemoteFile) URL() string { return f.rawURL }
// NewRequest constructs a request that remains bound to this validated file
// reference. Business shortcuts do not construct raw URL requests themselves.
func (f RemoteFile) NewRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
if f.rawURL == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"cannot build a request for an empty remote file reference")
}
req, err := http.NewRequestWithContext(ctx, method, f.rawURL, body)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"build remote file request: %v", err).WithCause(err)
}
return req, nil
}
// DirectRemoteFileValidator applies a caller-specific policy to direct-mode
// URLs. Proxy handles are validated by the managed data-plane policy instead.
type DirectRemoteFileValidator func(context.Context, string) error
// HTTPClientProvider resolves the runtime's configured HTTP client without
// exposing raw client construction to business shortcuts.
type HTTPClientProvider func() (*http.Client, error)
// RemoteFilePolicy is the source-neutral runtime contract for validating and
// routing service-returned file references.
type RemoteFilePolicy interface {
ValidateRemoteFile(rawURL string) error
UsesManagedFilePlane() bool
}
// RemoteFiles is the runtime boundary for service-returned file references.
// It keeps credential mode and edition routing out of business shortcuts.
// Every service-returned file byte transfer must pass through Validate,
// RemoteFile.NewRequest, and Do so the active data-plane policy is enforced.
type RemoteFiles struct {
policy RemoteFilePolicy
managedClient HTTPClientProvider
identity core.Identity
}
// NewRemoteFiles creates a file boundary for one resolved runtime identity.
func NewRemoteFiles(
policy RemoteFilePolicy,
managedClient HTTPClientProvider,
identity core.Identity,
) *RemoteFiles {
return &RemoteFiles{
policy: policy,
managedClient: managedClient,
identity: identity,
}
}
// Validate checks a known service-returned file URL and returns an opaque,
// typed reference. A directValidator, when supplied, is applied only to the
// ordinary/direct data plane; proxy handles use the configured proxy policy.
func (r *RemoteFiles) Validate(
ctx context.Context,
rawURL string,
directValidator ...DirectRemoteFileValidator,
) (RemoteFile, error) {
if r == nil {
return RemoteFile{}, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if r.policy != nil {
if err := r.policy.ValidateRemoteFile(rawURL); err != nil {
return RemoteFile{}, err
}
}
if !usesManagedFilePlane(r.policy) && len(directValidator) > 0 && directValidator[0] != nil {
if err := directValidator[0](ctx, rawURL); err != nil {
return RemoteFile{}, err
}
}
return RemoteFile{rawURL: rawURL}, nil
}
// RequirePortableURL rejects commands that would return a managed file handle
// for use outside this CLI. The check is capability-based, so callers do not
// branch on a credential mode.
func (r *RemoteFiles) RequirePortableURL(param string) error {
if r == nil || !usesManagedFilePlane(r.policy) {
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%s is unavailable because this credential source requires CLI-managed file routing", param).
WithParam(param).
WithHint("omit %s and let lark-cli transfer the file", param)
}
// Do executes a request for a validated remote file. Ordinary/direct runtimes
// use directClient unchanged; a nil directClient gets the same isolated
// DefaultTransport client historically used by presigned Apps transfers.
// Managed runtimes select the proxy-aware client and copy the caller's
// timeout/redirect policy so existing transfer semantics remain intact.
func (r *RemoteFiles) Do(req *http.Request, file RemoteFile, directClient *http.Client) (*http.Response, error) {
if r == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if req == nil || req.URL == nil || file.rawURL == "" || req.URL.String() != file.rawURL {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"remote file request does not match its validated reference")
}
if directClient == nil {
directClient = &http.Client{Transport: http.DefaultTransport}
}
selected := directClient
managedPlane := usesManagedFilePlane(r.policy)
if managedPlane {
if r.managedClient == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime has no HTTP client")
}
managed, err := r.managedClient()
if err != nil {
return nil, err
}
if managed == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime returned no HTTP client")
}
if directClient != nil && managed != directClient {
cloned := *managed
cloned.Timeout = directClient.Timeout
if directClient.CheckRedirect != nil {
cloned.CheckRedirect = directClient.CheckRedirect
}
selected = &cloned
} else {
selected = managed
}
}
if !managedPlane {
return selected.Do(req)
}
requestCtx := requestcontext.WithIdentity(req.Context(), r.identity)
return selected.Do(req.Clone(requestCtx))
}
func usesManagedFilePlane(policy RemoteFilePolicy) bool {
return policy != nil && policy.UsesManagedFilePlane()
}

View File

@@ -0,0 +1,317 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
const externalCredentialImplementation = "github.com/larksuite/cli/internal/externalcredential"
// These functions own protocols that are explicitly outside the
// service-returned file plane: user-supplied external downloads and MCP.
// Adding a boundary is an architectural decision; //nolint alone must never
// make a new raw HTTP path possible.
var shortcutRawHTTPBoundaries = map[string]string{
"common/mcp_client.go:DoMCPCall": "MCP protocol client",
"doc/doc_resource_cover.go:downloadDocCoverURL": "validated user-supplied cover URL",
"doc/doc_resource_cover.go:newDocCoverHTTPClient": "guarded external-download client",
"doc/doc_resource_cover.go:cloneDocCoverTransport": "guarded external-download transport",
"im/helpers.go:startURLDownload": "validated user-supplied media URL",
}
func TestCoreDoesNotOwnExternalCredentialProductModel(t *testing.T) {
checkCoreDirectoryIsProductNeutral(t, filepath.Join("..", "core"))
}
func TestRuntimeFrameworkDoesNotImportExternalCredentialImplementation(t *testing.T) {
for _, dir := range []string{
".",
filepath.Join("..", "cmdutil"),
filepath.Join("..", "credential"),
} {
checkDirectoryDoesNotImportExternalCredential(t, dir)
}
}
func checkCoreDirectoryIsProductNeutral(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
forbidden := []string{
"externalcredential",
"external-credential",
"credential_proxy",
"platform_proxy",
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkCoreDirectoryIsProductNeutral(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
normalizedSource := strings.ToLower(string(src))
for _, productSymbol := range forbidden {
if strings.Contains(normalizedSource, strings.ToLower(productSymbol)) {
t.Errorf("%s contains external credential product symbol %q; keep the core profile model product-neutral", path, productSymbol)
}
}
}
}
func TestBusinessShortcutsUseRuntimeCredentialBoundaries(t *testing.T) {
checkBusinessShortcutDirectory(t, filepath.Join("..", "..", "shortcuts"))
}
func TestShortcutRawHTTPConstructionIsConfinedToExplicitBoundaries(t *testing.T) {
root := filepath.Join("..", "..", "shortcuts")
checkShortcutRawHTTPDirectory(t, root, root)
}
func TestRawHTTPConstructionGuardRecognizesAliasedBypasses(t *testing.T) {
const source = `package fixture
import nethttp "net/http"
func bypass() {
_ = &nethttp.Client{}
_, _ = nethttp.NewRequest(nethttp.MethodGet, "https://files.example", nil)
_ = nethttp.DefaultClient
}`
file, err := parser.ParseFile(token.NewFileSet(), "fixture.go", source, 0)
if err != nil {
t.Fatal(err)
}
uses := rawHTTPConstructions(file, map[string]struct{}{"nethttp": {}})
joined := strings.Join(uses, ",")
for _, want := range []string{"Client literal", "NewRequest", "DefaultClient"} {
if !strings.Contains(joined, want) {
t.Fatalf("raw HTTP uses = %q, want %q", joined, want)
}
}
}
func checkDirectoryDoesNotImportExternalCredential(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkDirectoryDoesNotImportExternalCredential(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; inject a source-neutral runtime boundary", path)
}
}
}
}
func checkBusinessShortcutDirectory(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if entry.Name() != "common" {
checkBusinessShortcutDirectory(t, path)
}
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; use a source-neutral runtime capability boundary", path)
}
}
ast.Inspect(file, func(node ast.Node) bool {
selector, ok := node.(*ast.SelectorExpr)
if ok && selector.Sel.Name == "ExternalCredential" {
t.Errorf("%s selects ExternalCredential directly; use a runtime capability boundary", path)
}
return true
})
}
}
func checkShortcutRawHTTPDirectory(t *testing.T, root, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkShortcutRawHTTPDirectory(t, root, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
httpAliases := make(map[string]struct{})
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath != "net/http" {
continue
}
alias := "http"
if spec.Name != nil {
alias = spec.Name.Name
}
if alias == "." {
t.Errorf("%s dot-imports net/http; raw HTTP boundaries must remain statically auditable", path)
continue
}
if alias != "_" {
httpAliases[alias] = struct{}{}
}
}
if len(httpAliases) == 0 {
continue
}
relativePath, err := filepath.Rel(root, path)
if err != nil {
t.Fatalf("relative shortcut path for %s: %v", path, err)
}
relativePath = filepath.ToSlash(relativePath)
for _, decl := range file.Decls {
fn, isFunc := decl.(*ast.FuncDecl)
uses := rawHTTPConstructions(decl, httpAliases)
if len(uses) == 0 {
continue
}
if !isFunc {
t.Errorf("%s constructs raw net/http at package scope (%s); route service-returned file bytes through RuntimeContext.RemoteFiles",
path, strings.Join(uses, ", "))
continue
}
boundary := relativePath + ":" + fn.Name.Name
if _, allowed := shortcutRawHTTPBoundaries[boundary]; allowed {
continue
}
t.Errorf("%s function %s constructs raw net/http (%s); route service-returned file bytes through RuntimeContext.RemoteFiles or declare a reviewed non-file-plane boundary",
path, fn.Name.Name, strings.Join(uses, ", "))
}
}
}
func rawHTTPConstructions(node ast.Node, aliases map[string]struct{}) []string {
seen := make(map[string]struct{})
var uses []string
add := func(name string) {
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
uses = append(uses, name)
}
isHTTPSelector := func(selector *ast.SelectorExpr) bool {
ident, ok := selector.X.(*ast.Ident)
if !ok {
return false
}
_, ok = aliases[ident.Name]
return ok
}
ast.Inspect(node, func(current ast.Node) bool {
switch typed := current.(type) {
case *ast.CallExpr:
selector, ok := typed.Fun.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "NewRequest", "NewRequestWithContext", "Get", "Post", "PostForm", "Head", "Serve", "ListenAndServe":
add(selector.Sel.Name)
}
case *ast.CompositeLit:
selector, ok := typed.Type.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "Client", "Request", "Transport":
add(selector.Sel.Name + " literal")
}
case *ast.SelectorExpr:
if !isHTTPSelector(typed) {
break
}
switch typed.Sel.Name {
case "DefaultClient", "DefaultTransport":
add(typed.Sel.Name)
}
}
return true
})
return uses
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
type testRemoteFilePolicy struct {
allowed string
managed bool
}
func (p testRemoteFilePolicy) ValidateRemoteFile(rawURL string) error {
if p.allowed != "" && rawURL != p.allowed {
return errors.New("remote file rejected")
}
return nil
}
func (p testRemoteFilePolicy) UsesManagedFilePlane() bool { return p.managed }
func TestRemoteFilesDirectPreservesCallerClientAndPolicy(t *testing.T) {
var managedCalls, directCalls, validationCalls int
direct := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
directCalls++
if got := requestcontext.Identity(req.Context()); got != "" {
t.Fatalf("direct request context was changed: identity = %q", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
files := NewRemoteFiles(nil, func() (*http.Client, error) {
managedCalls++
return nil, errors.New("must not be called")
}, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/object?signature=x",
func(_ context.Context, rawURL string) error {
validationCalls++
if rawURL != "https://files.example/object?signature=x" {
t.Fatalf("validator URL = %q", rawURL)
}
return nil
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if directCalls != 1 || managedCalls != 0 || validationCalls != 1 {
t.Fatalf("calls direct=%d managed=%d validation=%d", directCalls, managedCalls, validationCalls)
}
}
func TestRemoteFilesManagedValidatesAndRoutesOpaqueHandle(t *testing.T) {
const opaqueURL = "https://proxy.example/lark-cli/v1/files/opaque_1"
policy := testRemoteFilePolicy{allowed: opaqueURL, managed: true}
var managedCalls, directCalls, directValidationCalls int
managed := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
managedCalls++
if got := requestcontext.Identity(req.Context()); got != core.AsBot {
t.Fatalf("request identity = %q, want bot", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
direct := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
directCalls++
return nil, errors.New("must not be called")
})}
files := NewRemoteFiles(policy, func() (*http.Client, error) { return managed, nil }, core.AsBot)
if _, err := files.Validate(context.Background(), "https://objects.example/raw?signature=secret"); err == nil {
t.Fatal("raw object URL accepted in managed mode")
}
file, err := files.Validate(context.Background(), opaqueURL,
func(context.Context, string) error {
directValidationCalls++
return errors.New("must not be called")
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if managedCalls != 1 || directCalls != 0 || directValidationCalls != 0 {
t.Fatalf("calls managed=%d direct=%d validation=%d", managedCalls, directCalls, directValidationCalls)
}
}
func TestRemoteFilesDoRejectsReferenceMismatch(t *testing.T) {
files := NewRemoteFiles(nil, nil, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/one")
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, "https://files.example/two", nil)
if _, err := files.Do(req, file, http.DefaultClient); err == nil {
t.Fatal("mismatched request and validated file accepted")
}
}
func TestRemoteFilesRequirePortableURL(t *testing.T) {
files := NewRemoteFiles(testRemoteFilePolicy{managed: true}, nil, core.AsBot)
err := files.RequirePortableURL("--url-only")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Param != "--url-only" {
t.Fatalf("error = subtype %q param %q", validationErr.Subtype, validationErr.Param)
}
}
func TestCallAPIPreservesURLsInArbitraryJSON(t *testing.T) {
const rawURL = "https://objects.example/raw?signature=opaque"
apiClient, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
resp := jsonResponse(map[string]any{
"code": 0,
"data": map[string]any{
"download_url": rawURL,
"nested": []any{map[string]any{"url": rawURL}},
},
})
resp.Request = req
return resp, nil
}))
result, err := apiClient.CallAPI(context.Background(), RawApiRequest{
Method: http.MethodGet,
URL: "/open-apis/example/v1/raw",
As: core.AsBot,
})
if err != nil {
t.Fatal(err)
}
data := result.(map[string]any)["data"].(map[string]any)
if got := data["download_url"]; got != rawURL {
t.Fatalf("download_url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
nested := data["nested"].([]any)[0].(map[string]any)
if got := nested["url"]; got != rawURL {
t.Fatalf("nested url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/runtimeplan"
)
// Factory holds shared dependencies injected into every command.
@@ -43,6 +44,8 @@ type Factory struct {
Credential *credential.CredentialProvider
runtimePlan *runtimeplan.Plan
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
@@ -57,6 +60,34 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
return f.FileIOProvider.ResolveFileIO(ctx)
}
// NewRemoteFiles returns the invocation's file-transfer boundary without
// exposing credential source, mode, or edition to business shortcuts.
func (f *Factory) NewRemoteFiles(identity core.Identity) *client.RemoteFiles {
if f == nil {
return client.NewRemoteFiles(nil, nil, identity)
}
return client.NewRemoteFiles(runtimeplan.Ensure(f.runtimePlan), f.HttpClient, identity)
}
// RuntimeDescription returns sanitized diagnostics for the active plan.
func (f *Factory) RuntimeDescription() runtimeplan.Description {
if f == nil {
return runtimeplan.Description{}
}
return runtimeplan.Ensure(f.runtimePlan).Describe()
}
// RuntimeStartupError reports whether invocation bootstrap failed before an
// effective credential/runtime configuration could be selected. It exposes
// only the source-neutral plan contract so diagnostic commands do not need to
// know which edition or credential product produced the failure.
func (f *Factory) RuntimeStartupError() error {
if f == nil {
return nil
}
return runtimeplan.Ensure(f.runtimePlan).StartupError()
}
// ResolveAs returns the effective identity type.
// If the user explicitly passed --as, use that value; otherwise use the configured default.
// When the value is "auto" (or unset), auto-detect based on credential hints.
@@ -210,26 +241,3 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
Credential: f.Credential,
}, nil
}
// 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)
// - No extension provider is active (built-in keychain/config path is used)
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
if f.Credential == nil {
return nil
}
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if provName == "" {
return nil
}
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

@@ -22,64 +22,105 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
"github.com/larksuite/cli/internal/runtimeplan"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
var (
initRegistryWithBrand = registry.InitWithBrand
initEmbeddedRegistryWithBrand = registry.InitEmbeddedWithBrand
)
// NewDefault creates a production Factory with cached closures.
// Initialization follows a credential-first order:
//
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Preserve the established standalone Factory behavior. Product-specific
// runtime selection belongs to the CLI composition root, which calls
// NewDefaultWithRuntimePlan with one immutable startup snapshot.
core.SetCurrentWorkspace(core.DetectWorkspaceFromEnv(os.Getenv))
return newDefaultWithRuntimePlan(streams, inv, nil, runtimeplan.Default(), false)
}
// NewDefaultWithRuntimePlan creates a production Factory from the same
// immutable Profile snapshot and source-neutral plan used by startup routing.
func NewDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
) *Factory {
return newDefaultWithRuntimePlan(streams, inv, profileConfig, plan, true)
}
func newDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
useProfileSnapshot bool,
) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
runtimePlan: runtimeplan.Ensure(plan),
}
// Workspace detection: determines which config subtree to use.
// Must run before any config or credential load, since those paths are
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
keychain.RuntimeDirFunc = core.GetRuntimeDir
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
if profileConfig != nil {
workspaceConfig = core.NewConfigSnapshotFrom(profileConfig)
}
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
RuntimePlan: f.runtimePlan,
ProfileConfigSnapshot: profileConfig,
UseProfileSnapshot: useProfileSnapshot,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return nil, err
}
cfg := acct.ToCliConfig()
registry.InitWithBrand(cfg.Brand)
if f.runtimePlan.AllowsRemoteMetadata() {
initRegistryWithBrand(cfg.Brand)
} else {
// Defense in depth for callers that construct a Factory directly
// instead of going through cmd/build's composition root.
initEmbeddedRegistryWithBrand(cfg.Brand)
}
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +149,23 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
var err error
rt, err = applyRuntimePlan(f, rt)
if err != nil {
return nil, err
}
// Risk control remains the final trusted header boundary before either
// the ordinary network transport or the managed proxy data plane.
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +179,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +193,20 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
sdkBase, err = applyRuntimePlan(f, sdkBase)
if err != nil {
return nil, err
}
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection. A managed runtime applies its data-plane policy after this
// boundary so trusted signals remain associated with the original request.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,26 +215,56 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
sdkTransport = wrapWithExtension(sdkTransport)
return sdkTransport
}
func applyRuntimePlan(f *Factory, base http.RoundTripper) (http.RoundTripper, error) {
if f == nil {
return base, nil
}
return runtimeplan.Ensure(f.runtimePlan).Wrap(base)
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
RuntimePlan *runtimeplan.Plan
ProfileConfigSnapshot *core.MultiAppConfig
UseProfileSnapshot bool
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
plan := runtimeplan.Ensure(deps.RuntimePlan)
providers := extcred.Providers()
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
localAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
if deps.UseProfileSnapshot {
localAcct = credential.NewDefaultAccountProviderFromSnapshot(deps.Keychain, deps.Profile, deps.ProfileConfigSnapshot)
}
localToken := credential.NewDefaultTokenProvider(localAcct, deps.HttpClient, deps.ErrOut)
var defaultAcct credential.DefaultAccountResolver = localAcct
var defaultToken credential.DefaultTokenResolver = localToken
if startupErr := plan.StartupError(); startupErr != nil {
providers = []extcred.Provider{&runtimePlanErrorProvider{err: startupErr}}
defaultAcct = nil
defaultToken = nil
} else if provider, replace := plan.CredentialProvider(); provider != nil {
if replace {
providers = []extcred.Provider{provider}
defaultAcct = nil
defaultToken = nil
} else {
providers = append([]extcred.Provider{provider}, providers...)
}
}
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
// happens before the command runs, so any plain-text warning written
// to stderr would break the JSON envelope contract that AI agents
@@ -180,3 +273,15 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}
type runtimePlanErrorProvider struct{ err error }
func (p *runtimePlanErrorProvider) Name() string { return "runtime-policy" }
func (p *runtimePlanErrorProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return nil, p.err
}
func (p *runtimePlanErrorProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, p.err
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
@@ -14,6 +15,9 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -213,3 +217,63 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
func TestRuntimePlanMetadataPolicyIsEnforcedByFactory(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
originalRemote := initRegistryWithBrand
originalEmbedded := initEmbeddedRegistryWithBrand
t.Cleanup(func() {
initRegistryWithBrand = originalRemote
initEmbeddedRegistryWithBrand = originalEmbedded
})
var remoteCalls, embeddedCalls int
initRegistryWithBrand = func(core.LarkBrand) { remoteCalls++ }
initEmbeddedRegistryWithBrand = func(core.LarkBrand) { embeddedCalls++ }
profile := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}}}
plan := runtimeplan.New(runtimeplan.Options{Metadata: runtimeplan.MetadataEmbeddedOnly})
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, profile, plan)
if _, err := f.Config(); err != nil {
t.Fatalf("Config() error = %v", err)
}
if remoteCalls != 0 || embeddedCalls != 1 {
t.Fatalf("registry init calls = remote:%d embedded:%d, want remote:0 embedded:1", remoteCalls, embeddedCalls)
}
}
func TestCLICompositionPreservesEnvironmentCredentialsWhenProfileIsUnreadable(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliExternalCredentialConfig,
filepath.Join(configDir, "missing-external-credential.json"))
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := vfs.MkdirAll(core.GetConfigPath(), 0o700); err != nil {
t.Fatal(err)
}
startup := runtimebootstrap.Resolve("")
if err := startup.Plan.StartupError(); err != nil {
t.Fatalf("runtime bootstrap changed legacy environment fallback: %v", err)
}
f := NewDefaultWithRuntimePlan(nil, InvocationContext{}, startup.ProfileConfig, startup.Plan)
account, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdutil
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"testing"
_ "github.com/larksuite/cli/extension/credential/env"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/externalcredential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/requestcontext"
"github.com/larksuite/cli/internal/riskcontrol"
"github.com/larksuite/cli/internal/runtimebootstrap"
internaltransport "github.com/larksuite/cli/internal/transport"
)
func newFactoryFromRuntimeBootstrap(streams *IOStreams, inv InvocationContext) *Factory {
startup := runtimebootstrap.Resolve(inv.Profile)
return NewDefaultWithRuntimePlan(streams, inv, startup.ProfileConfig, startup.Plan)
}
func writePlatformProxyConfiguration(t *testing.T, config *core.MultiAppConfig, appID string) {
t.Helper()
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
systemPath := configDir + "/external-credential.json"
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
system := externalcredential.Config{
Version: 1, Mode: externalcredential.ModePlatformProxy,
RemoteEndpoint: "https://credentials.example.com",
Applications: []externalcredential.Application{{Brand: core.BrandFeishu, AppID: appID}},
}
data, err := json.Marshal(system)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemPath, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestFactoryInstallsExternalProxyTransport(t *testing.T) {
t.Setenv(internaltransport.EnvNoProxy, "")
previousTransport := http.DefaultTransport
var observed *http.Request
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
observed = req.Clone(req.Context())
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "sandbox", AppId: "cli_test", Brand: core.BrandFeishu, Lang: i18n.LangJaJP, Users: []core.AppUser{},
}}}
writePlatformProxyConfiguration(t, config, "cli_test")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
resolved, err := factory.Config()
if err != nil {
t.Fatal(err)
}
if resolved.Lang != i18n.LangJaJP {
t.Fatalf("resolved Lang = %q, want Profile Lang %q", resolved.Lang, i18n.LangJaJP)
}
client, err := factory.HttpClient()
if err != nil {
t.Fatal(err)
}
ctx := requestcontext.WithIdentity(context.Background(), core.AsUser)
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://open.feishu.cn/open-apis/test/v1/ping", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if observed == nil ||
observed.URL.String() != "https://credentials.example.com/lark-cli/v1/openapi/open-apis/test/v1/ping" ||
observed.Header.Get(externalcredential.HeaderAppID) != "cli_test" ||
observed.Header.Get(externalcredential.HeaderIdentity) != "user" ||
observed.Header.Get(riskcontrol.HeaderOSType) == "" {
t.Fatalf("managed data-plane request = %#v", observed)
}
}
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsMalformed(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := os.WriteFile(core.GetConfigPath(), []byte(`{"apps":[`), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}
func TestFactoryKeepsEnvironmentProviderWhenLegacyConfigIsUnreadable(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
if err := os.Mkdir(core.GetConfigPath(), 0700); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if account.AppID != "cli_env" {
t.Fatalf("account AppID = %q, want environment account", account.AppID)
}
}
func TestFactoryRejectsMisspelledExternalCredentialInsteadOfFallingBack(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredentials":{"mode":"proxy"}}]}`
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected misspelled external credential profile to fail closed")
}
}
func TestFactoryRejectsNullExternalCredentialInsteadOfFallingBack(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
core.SetCurrentWorkspace(core.WorkspaceLocal)
config := `{"apps":[{"appId":"cli_external","brand":"feishu","users":[],"externalCredential":null}]}`
if err := os.WriteFile(core.GetConfigPath(), []byte(config), 0600); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected null external credential profile to fail closed")
}
}
func TestFactoryRejectsMissingSelectedExternalCredentialProfile(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_env")
t.Setenv(envvars.CliTenantAccessToken, "tenant-token")
config := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "sandbox", AppId: "cli_external", Brand: core.BrandFeishu, Users: []core.AppUser{},
}},
}
writePlatformProxyConfiguration(t, config, "cli_external")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.Credential.ResolveAccount(context.Background()); err == nil {
t.Fatal("expected missing selected external credential profile to fail closed")
}
}
func TestFactoryUsesOneProfileSnapshotForCredentialAndTransport(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
systemPath := configDir + "/external-credential.json"
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
for _, name := range []string{
envvars.CliAppID, envvars.CliAppSecret, envvars.CliUserAccessToken,
envvars.CliTenantAccessToken, envvars.CliAuthProxy, envvars.CliProxyKey,
envvars.CliProxyEnable, envvars.CliProxyAddress, envvars.CliCAPath,
} {
t.Setenv(name, "")
}
core.SetCurrentWorkspace(core.WorkspaceLocal)
initial := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "initial", AppId: "cli_initial", AppSecret: core.PlainSecret("initial-secret"),
Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
if err := core.SaveMultiAppConfig(initial); err != nil {
t.Fatal(err)
}
factory := newFactoryFromRuntimeBootstrap(&IOStreams{In: nil, Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
replacement := &core.MultiAppConfig{Apps: []core.AppConfig{{
Name: "replacement", AppId: "cli_proxy", Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
if err := core.SaveMultiAppConfig(replacement); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemPath, []byte(`{"version":1,"mode":"platform_proxy","remoteEndpoint":"https://credentials.example.com","applications":[{"brand":"feishu","appId":"cli_proxy"}]}`), 0o600); err != nil {
t.Fatal(err)
}
account, err := factory.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if account.AppID != "cli_initial" {
t.Fatalf("account AppID = %q, want immutable snapshot cli_initial", account.AppID)
}
client, err := factory.HttpClient()
if err != nil {
t.Fatal(err)
}
if _, ok := client.Transport.(*externalcredential.Transport); ok {
t.Fatalf("transport = %T, should match initial non-proxy profile", client.Transport)
}
}
func TestFactoryRejectsProxyModeWithTransportExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", Brand: core.BrandFeishu, Users: []core.AppUser{},
}}}
writePlatformProxyConfiguration(t, config, "cli_test")
factory := newFactoryFromRuntimeBootstrap(&IOStreams{Out: io.Discard, ErrOut: io.Discard}, InvocationContext{})
if _, err := factory.HttpClient(); err == nil {
t.Fatal("expected proxy mode and transport extension to be rejected")
}
}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimeplan"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
@@ -413,13 +414,13 @@ func (s *stubExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (
return nil, nil
}
func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
func TestRequireRuntimeCapabilities_BlocksProviderOwnedCredentials(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -439,25 +440,44 @@ func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
}
}
func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
func TestRequireRuntimeCapabilities_DefaultCommandLabel(t *testing.T) {
stub := &stubExtProvider{name: "env", acct: &extcred.Account{AppID: "app"}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireRuntimeCapabilities(context.Background(), "", runtimeplan.CapabilityLocalCredentialManagement)
if err == nil {
t.Fatal("expected error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed error", err)
}
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestRequireRuntimeCapabilities_AllowsLocalCredentialProvider(t *testing.T) {
// No extension providers → built-in path → no error
f, _, _, _ := TestFactory(t, nil)
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
func TestRequireRuntimeCapabilities_AllowsNilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = nil
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if err != nil {
t.Fatalf("unexpected error with nil Credential: %v", err)
}
}
func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T) {
func TestRequireRuntimeCapabilities_PropagatesProviderError(t *testing.T) {
sentinel := errors.New("provider unavailable")
stub := &stubExtProvider{name: "env", err: sentinel}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
@@ -465,7 +485,7 @@ func TestRequireBuiltinCredentialProvider_PropagatesProviderError(t *testing.T)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
err := f.RequireRuntimeCapabilities(context.Background(), "auth", runtimeplan.CapabilityLocalCredentialManagement)
if !errors.Is(err, sentinel) {
t.Fatalf("error = %v, want sentinel", err)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/runtimeplan"
)
const (
runtimeCapabilitiesAnnotation = "lark:runtimeCapabilities"
noRuntimeCapabilities = "-"
)
// SetRuntimeCapabilities declares the runtime surfaces required by a command.
// A zero-capability declaration explicitly overrides a parent default.
func SetRuntimeCapabilities(cmd *cobra.Command, capabilities ...runtimeplan.Capability) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
if len(capabilities) == 0 {
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
return
}
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if capability != "" {
values = append(values, string(capability))
}
}
if len(values) == 0 {
cmd.Annotations[runtimeCapabilitiesAnnotation] = noRuntimeCapabilities
return
}
cmd.Annotations[runtimeCapabilitiesAnnotation] = strings.Join(values, ",")
}
// GetRuntimeCapabilities resolves the nearest explicit declaration from the
// leaf command toward its parents.
func GetRuntimeCapabilities(cmd *cobra.Command) []runtimeplan.Capability {
for current := cmd; current != nil; current = current.Parent() {
raw, ok := current.Annotations[runtimeCapabilitiesAnnotation]
if !ok {
continue
}
if raw == "" || raw == noRuntimeCapabilities {
return nil
}
parts := strings.Split(raw, ",")
out := make([]runtimeplan.Capability, 0, len(parts))
for _, part := range parts {
if capability := strings.TrimSpace(part); capability != "" {
out = append(out, runtimeplan.Capability(capability))
}
}
return out
}
return nil
}
// RequireRuntimeCapabilities applies the invocation plan and the selected
// credential source to a command's source-neutral capability declaration.
func (f *Factory) RequireRuntimeCapabilities(
ctx context.Context,
command string,
capabilities ...runtimeplan.Capability,
) error {
if f == nil {
return nil
}
plan := runtimeplan.Ensure(f.runtimePlan)
for _, capability := range capabilities {
if err := plan.Require(capability); err != nil {
return err
}
if capability != runtimeplan.CapabilityLocalCredentialManagement || f.Credential == nil {
continue
}
providerName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if providerName == "" {
continue
}
if command == "" {
command = "credential management"
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%q cannot manage credentials owned by provider %q", command, providerName).
WithHint("manage authorization through the active credential provider, or use a local Profile credential source")
}
return nil
}
// RequireCommandRuntimeCapabilities checks the declaration resolved for cmd.
func (f *Factory) RequireCommandRuntimeCapabilities(ctx context.Context, cmd *cobra.Command) error {
command := "credential management"
if cmd != nil {
command = cmd.CommandPath()
}
return f.RequireRuntimeCapabilities(ctx, command, GetRuntimeCapabilities(cmd)...)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/spf13/cobra"
)
func TestRuntimeCapabilitiesUseNearestDeclaration(t *testing.T) {
parent := &cobra.Command{Use: "auth"}
SetRuntimeCapabilities(parent, runtimeplan.CapabilityLocalCredentialManagement)
inherited := &cobra.Command{Use: "login"}
parent.AddCommand(inherited)
got := GetRuntimeCapabilities(inherited)
if len(got) != 1 || got[0] != runtimeplan.CapabilityLocalCredentialManagement {
t.Fatalf("inherited capabilities = %v", got)
}
diagnostic := &cobra.Command{Use: "status"}
SetRuntimeCapabilities(diagnostic)
parent.AddCommand(diagnostic)
if got := GetRuntimeCapabilities(diagnostic); len(got) != 0 {
t.Fatalf("explicit empty capabilities = %v", got)
}
}
func TestRequireRuntimeCapabilitiesUsesPlan(t *testing.T) {
denied := errors.New("events denied")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityRealtimeEvents {
return denied
}
return nil
},
})
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil, plan)
err := f.RequireRuntimeCapabilities(context.Background(), "event consume", runtimeplan.CapabilityRealtimeEvents)
if !errors.Is(err, denied) {
t.Fatalf("error = %v, want plan denial", err)
}
}
func TestRequireRuntimeCapabilitiesKeepsPurelyLocalCommandsAvailable(t *testing.T) {
startupErr := errors.New("managed runtime bootstrap failed")
f, _, _, _ := TestFactoryWithRuntimePlan(t, nil,
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly))
if err := f.RequireRuntimeCapabilities(context.Background(), "local recovery"); err != nil {
t.Fatalf("capability-free local command = %v, want available", err)
}
}
func TestRequireRuntimeCapabilitiesBlocksProviderOwnedCredentials(t *testing.T) {
provider := &runtimeCapabilityProvider{}
f, _, _, _ := TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{provider}, nil, nil, nil,
)
err := f.RequireRuntimeCapabilities(
context.Background(),
"auth login",
runtimeplan.CapabilityLocalCredentialManagement,
)
if err == nil {
t.Fatal("expected provider-owned credentials to reject local management")
}
if err := f.RequireRuntimeCapabilities(
context.Background(),
"profile use",
runtimeplan.CapabilityLocalProfileMutation,
); err != nil {
t.Fatalf("generic provider unexpectedly blocked Standard Profile mutation: %v", err)
}
}
type runtimeCapabilityProvider struct{}
func (*runtimeCapabilityProvider) Name() string { return "test-provider" }
func (*runtimeCapabilityProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "cli_test"}, nil
}
func (*runtimeCapabilityProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}

View File

@@ -26,6 +26,7 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
if v := envvars.AgentName(); v != "" {
h.Set(HeaderAgentName, v)
}
return h
}

View File

@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// Agent headers injected via BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentName, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(envvars.CliAgentName, agentName)
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != agentName {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
}
}
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
@@ -76,6 +77,29 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
return f, stdoutBuf, stderrBuf, reg
}
// TestFactoryWithRuntimePlan creates a TestFactory with an explicit
// source-neutral runtime policy.
func TestFactoryWithRuntimePlan(
t *testing.T,
config *core.CliConfig,
plan *runtimeplan.Plan,
) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
f, out, errOut, registry := TestFactory(t, config)
f.runtimePlan = runtimeplan.Ensure(plan)
return f, out, errOut, registry
}
// TestSetRuntimePlan replaces a Factory's runtime policy in tests that need to
// preserve an existing HTTP mock registry or other custom wiring.
func TestSetRuntimePlan(t *testing.T, f *Factory, plan *runtimeplan.Plan) {
t.Helper()
if f == nil {
t.Fatal("cannot install a runtime plan on a nil Factory")
}
f.runtimePlan = runtimeplan.Ensure(plan)
}
type testDefaultAcct struct {
config *core.CliConfig
}

View File

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
// NewConfigSnapshotFrom creates a snapshot from configuration that was
// already captured by another invocation-start boundary.
func NewConfigSnapshotFrom(config *MultiAppConfig) *ConfigSnapshot {
return newConfigSnapshot(func() (*MultiAppConfig, error) {
return config, nil
})
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestNewConfigSnapshotFromPreservesCapturedConfig(t *testing.T) {
want := &MultiAppConfig{CurrentApp: "captured"}
snapshot := NewConfigSnapshotFrom(want)
got, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if got != want || got.CurrentApp != "captured" {
t.Fatalf("MultiAppConfig() = %#v, want captured pointer %#v", got, want)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

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