Compare commits

..

44 Commits

Author SHA1 Message Date
liujinkun
8e19aae336 docs(wiki): address node routing review feedback 2026-07-31 18:32:30 +08:00
liujinkun
6f542aafe2 docs(wiki): route node resolution through shortcut 2026-07-31 15:42:49 +08:00
chenxingyang1019
41692b7041 feat(apps): add cache debug commands (+cache-get/-delete/-clear) (#1896)
* feat(apps): add cache debug commands (+cache-get/-delete/-clear)

Add three apps-domain cache debug shortcuts for inspecting/clearing an app's
runtime cache:
- +cache-get: read a business key's value + metadata (hit/miss)
- +cache-delete: delete a single key (idempotent, write)
- +cache-clear: clear all cache in an environment (high-risk-write, --yes)

value renders raw on --format json, deserialized on --format pretty;
value_size_bytes is computed CLI-side; --environment auto-selects the branch
when omitted. Includes unit tests (hit/miss/dry-run/confirmation) and the
lark-apps cache skill reference.

* fix(apps): normalize cache numeric output fields and tidy comments

Follow-up hardening for the cache debug commands (+cache-get/-delete/-clear):

- Normalize ttl_ms / deleted_key_count via a new cacheInt() helper so
  --format json emits a stable JSON number (or null) regardless of whether
  the server sends the value as a number or a string. Aligns with the
  repo convention that numeric wire fields may arrive as strings; previously
  these were passed through raw, leaving the output type at the server's mercy.
- Add unit tests locking the string-wire -> JSON number contract for both
  cache-get ttl_ms and cache-delete deleted_key_count.
- Tidy two comments: soften cacheBool's speculative "historical wire form"
  claim to a defensive-tolerance note, and drop implementation jargon from
  cache-delete's risk-level rationale.
2026-07-31 14:13:56 +08:00
dc-bytedance
b79827d60a fix: drop stale target version from root upgrade prompt (#2100) 2026-07-31 12:45:43 +08:00
zhaojiaxing-coding
0f35676a28 feat(drive): extend permission shortcuts for Miaoda (#2070)
* feat(drive): support Miaoda apps in permission shortcuts

Extend Drive permission shortcuts to accept Miaoda page URLs and the apps resource type while keeping each endpoint's accepted resource contract explicit.

Key features:

- Infer apps from /page/ URLs and accept explicit --type=apps in +apply-permission, +member-add, +member-list, and +permission-get-setting

- Decouple secure-label target parsing so expanding apply-permission does not widen secure-label support

- Align skill guidance and unit/dry-run coverage with the new resource type

* test(drive): cover apps permission target validation

Add focused coverage for Miaoda apps target handling across apply-permission and secure-label boundaries.

Exercise malformed page URLs, explicit apps bare tokens, typed validation errors, and command-level rejection so future resource-type changes cannot silently widen unsupported secure-label behavior.

* fix(drive): parse permission markers from URL paths

Keep drive +apply-permission resource inference aligned with URL component boundaries. Parse and validate URL inputs before extracting tokens so query strings and fragments cannot redirect permission requests to a different resource.

Key fixes:

- Match document and apps markers only against the parsed URL path

- Reject malformed URLs with a typed --token validation error

- Cover /page/ markers found only in query strings or fragments

* docs(skills): redact Miaoda page token example

Replace the concrete Miaoda page token with a representative pagcn placeholder. This keeps the token shape recognizable while avoiding exposure of a real resource identifier in the skill documentation.

* fix(drive): harden permission target resolution

Make Drive shortcut targets unambiguous before they reach read or write API paths. URL inputs now bind to a recognized root path and a single validated token segment, preventing encoded separators, dot segments, and type conflicts from silently changing the addressed resource.

Key fixes:

- Reject non-root URLs, dot/traversal tokens, and URL/type conflicts for secure-label and permission-apply writes

- Keep permission-setting URL parsing and pretty output reversible for every supported command-local resource kind

- Add unit and dry-run E2E regressions plus aligned permission-apply guidance
2026-07-31 12:16:22 +08:00
wangweiming-01
946964e093 fix(drive): use title for default download filename (#2089) 2026-07-31 12:12:11 +08:00
HanShaoshuai-k
cfe76ad56a ci: add protected public domain allowlists (#2111)
Co-authored-by: HanShaoshuai-k <268785735+HanShaoshuai-k@users.noreply.github.com>
2026-07-31 11:02:04 +08:00
calendar-assistant
fa9c30c690 docs(calendar): confirm scope before editing recurring events (#2119)
Promote the recurring-event rule to a pre-routing gate so it is read
before the specific operation flow, and require confirming the scope
(this event / all / this-and-following) when the user is ambiguous
instead of defaulting to this-event-only. Removes the redundant and
conflicting "edit existing event" row that hard-coded the single-
instance default.
2026-07-30 21:59:08 +08:00
zcc
ba95252019 feat(drive): add comment-operation shortcuts (#1898)
Add comment-domain shortcuts: +batch-query-comments, +resolve-comment,
+restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply
and +react-reply, sharing one target resolver with per-endpoint file_type
sets.

Flatten the comment reference docs by dropping the comments-guide routing
layer and folding its cross-command knowledge into the command refs:
comment-card model, comment/reply/interaction counting and sorting rules
into lark-drive-list-comments.md; the --solved-status prerequisite into
lark-drive-restore-comment.md; the apps exception into
lark-drive-add-comment.md. Comment intents now route straight from the
drive SKILL.md Shortcuts table to each command ref.

Cover the new shortcuts with unit tests, dry-run e2e and live workflow
e2e behind LARK_DRIVE_MD_COMMENT_E2E=1, and register them in
tests/cli_e2e/drive/coverage.md.
2026-07-30 21:53:21 +08:00
zhouyue-bytedance
4a16139348 fix(base): resolve Base URL block types accurately (#2099)
* fix: resolve Base URL block types accurately

* fix: resolve Base block selection from Wiki URLs

* fix(base): guide resolved folder and docx blocks

* fix(base): avoid field fallback for untyped URL blocks

* docs(base): specify URL example fence language

* test(base): cover unmatched URL block resolution
2026-07-30 20:29:47 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +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
218 changed files with 21135 additions and 4846 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

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

@@ -2,6 +2,61 @@
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
@@ -1667,6 +1722,9 @@ 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

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

@@ -386,7 +386,7 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
appInfoStub := &httpmock.Stub{
Method: http.MethodGet,
@@ -442,7 +442,7 @@ func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T)
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
@@ -485,18 +485,6 @@ type authScopesTokenResolver struct {
requests []credential.TokenSpec
}
type authTestAccountResolver struct {
appID string
}
func (r authTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
}
func newAuthTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, authTestAccountResolver{appID: appID}, tokenResolver, nil)
}
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.requests = append(r.requests, req)
switch req.Type {

View File

@@ -27,9 +27,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
cmd := &cobra.Command{
Use: "status",
Short: "View current auth status",
Long: `Show OAuth user login, token validity, and granted scopes.
For token-validity checks, run lark-cli auth status --json --verify.
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)

View File

@@ -4,35 +4,15 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
cmd := NewCmdAuthStatus(nil, nil)
for _, want := range []string{
"OAuth user login",
"auth status --json --verify",
"not profile/app selection diagnostics",
"lark-cli whoami",
} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
}
}
}
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
@@ -99,51 +79,6 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
}
}
type fixedStatusAccountResolver struct {
account *credential.Account
}
func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return r.account, nil
}
func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, config)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
nil,
nil,
).WithProfileFromFlag("tenant_a")
cmd := NewCmdAuth(f)
cmd.SetArgs([]string{"status", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status should use the selected built-in profile: %v", err)
}
if strings.Contains(stdout.String(), "credentials are provided externally") {
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
}
}
type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`

View File

@@ -6,10 +6,8 @@ package cmd
import (
"errors"
"io"
"os"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/spf13/pflag"
)
@@ -28,13 +26,5 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
profileFromFlag := fs.Changed("profile")
if !profileFromFlag {
globals.Profile = os.Getenv(envvars.CliProfile)
}
return cmdutil.InvocationContext{
Profile: globals.Profile,
ProfileFromFlag: profileFromFlag,
}, nil
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
}

View File

@@ -3,11 +3,7 @@
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/envvars"
)
import "testing"
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
@@ -74,58 +70,3 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}
func TestBootstrapProfileEnvFallback(t *testing.T) {
t.Run("flag wins over env", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_flag" {
t.Errorf("got %q, want tenant_flag", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("explicit empty flag clears env selection", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"--profile=", "whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if !inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = false, want true")
}
})
t.Run("env used when flag absent", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "tenant_env")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "tenant_env" {
t.Errorf("got %q, want tenant_env", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
t.Run("empty when neither set", func(t *testing.T) {
t.Setenv(envvars.CliProfile, "")
inv, err := BootstrapInvocationContext([]string{"whoami"})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if inv.Profile != "" {
t.Errorf("got %q, want empty", inv.Profile)
}
if inv.ProfileFromFlag {
t.Errorf("ProfileFromFlag = true, want false")
}
})
}

View File

@@ -84,16 +84,6 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
cmd := NewCmdConfigShow(nil, nil)
if !strings.Contains(cmd.Short, "saved config") {
t.Errorf("config show short = %q, want saved config", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("config show help missing whoami route")
}
}
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -116,77 +106,6 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
}
}
// config show promises "saved config, not current usage" (help + skill
// routing): the session profile (--profile / LARKSUITE_CLI_PROFILE) must not
// change what it shows.
func TestConfigShowRun_IgnoresSessionProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{
{Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu},
{Name: "tenant_b", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret-b"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Invocation.Profile = "tenant_b" // session selection must not leak in
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"cli_a"`) || !strings.Contains(out, `"tenant_a"`) {
t.Fatalf("output = %s, want the saved default tenant_a/cli_a", out)
}
if strings.Contains(out, `"cli_b"`) {
t.Fatalf("output = %s, session profile tenant_b must not change saved-config view", out)
}
}
// engagedEnvStub simulates a fully engaged external credential provider.
type engagedEnvStub struct{}
func (engagedEnvStub) Name() string { return "env" }
func (engagedEnvStub) Priority() int { return 10 }
func (engagedEnvStub) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "cli_env", AppSecret: "your-password"}, nil // managed takeover
}
func (engagedEnvStub) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// config show inspects the SAVED config only, so the parent command's
// external-credential gate must not apply: even with a fully engaged direct
// env credential, `config show` still answers from the saved config.
func TestConfigShow_BypassesExternalCredentialGate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider([]extcred.Provider{engagedEnvStub{}}, nil, nil, nil)
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"show"})
if err := cmd.Execute(); err != nil {
t.Fatalf("config show must bypass the external-credential gate: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"cli_a"`) {
t.Fatalf("output = %s, want the saved config shown", out)
}
}
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
@@ -562,8 +481,7 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
// "show" is deliberately absent: it inspects the SAVED config only
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}

View File

@@ -27,16 +27,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
cmd := &cobra.Command{
Use: "show",
Short: "Show saved config",
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
// Override parent's RequireBuiltinCredentialProvider check: this
// command reads the SAVED config only (its own help promises "saved
// config, not current usage"), so the currently effective credential
// source — external or otherwise — must not gate it.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
Short: "Show current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
@@ -62,10 +53,7 @@ func configShowRun(opts *ConfigShowOptions) error {
if config == nil || len(config.Apps) == 0 {
return core.NotConfiguredError()
}
// Saved config only: the session profile (--profile / LARKSUITE_CLI_PROFILE)
// must not change what this command shows — the help and skill routing
// promise "saved config, not current usage" (use whoami for that).
app := config.CurrentAppConfig("")
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
}

View File

@@ -110,20 +110,8 @@ func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
return nil, errors.New("backend unavailable")
}
type eventTestAccountResolver struct {
appID string
}
func (r eventTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newEventTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, eventTestAccountResolver{appID: appID}, tokenResolver, nil)
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {

View File

@@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,

View File

@@ -17,14 +17,11 @@ import (
)
// profileListItem is the JSON output for a single profile entry.
// `default` (formerly `active`, renamed in this feature as a declared
// breaking change) marks the saved default profile — never the identity
// effective for the current invocation; that is whoami's job.
type profileListItem struct {
Name string `json:"name"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
Default bool `json:"default"`
Active bool `json:"active"`
User string `json:"user,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
@@ -33,8 +30,7 @@ type profileListItem struct {
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List saved profiles",
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
Short: "List all profiles",
RunE: func(cmd *cobra.Command, args []string) error {
return profileListRun(f)
},
@@ -57,7 +53,7 @@ func profileListRun(f *cmdutil.Factory) error {
return nil
}
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
currentApp := multi.CurrentAppConfig("")
currentName := ""
if currentApp != nil {
@@ -70,10 +66,10 @@ func profileListRun(f *cmdutil.Factory) error {
name := app.ProfileName()
item := profileListItem{
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Default: name == currentName,
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Active: name == currentName,
}
if len(app.Users) > 0 {

View File

@@ -14,17 +14,6 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
Long: `Profiles are named app identities managed by lark-cli.
Identity diagnostics and profile selection:
lark-cli whoami --json Show the app/profile lark-cli is using now.
lark-cli auth status --json --verify Verify OAuth login and token state.
--profile <name> Use a profile for this command only.
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
config show / profile list Inspect saved config, not current usage.
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.
A selected profile takes precedence over matching direct env credentials and tokens.`,
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetTips(cmd, []string{

View File

@@ -306,24 +306,14 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
}
raw := stdout.String()
// `active` is renamed to `default` as a declared breaking change: keeping
// a permanently mirrored alias would keep misleading agents into reading
// it as the currently effective identity (whoami's job).
if strings.Contains(raw, `"active"`) {
t.Fatalf("profile list output contains renamed active field: %s", raw)
}
if !strings.Contains(raw, `"default"`) {
t.Fatalf("profile list output missing default field: %s", raw)
}
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0].Name != "default" || !got[0].Default {
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
if got[0].Name != "default" || !got[0].Active {
t.Fatalf("got[0] = %#v, want active default profile", got[0])
}
if got[1].Name != "target" || got[1].Default {
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
if got[1].Name != "target" || got[1].Active {
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
}
}
@@ -637,39 +627,6 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
})
}
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
// per-invocation flag and session-scoped env var for selecting a profile, so
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
func TestProfileHelpHasSelectionSection(t *testing.T) {
cmd := NewCmdProfile(nil)
if !strings.Contains(cmd.Long, "Identity diagnostics and profile selection:") {
t.Errorf("profile --help missing identity diagnostics and profile selection section")
}
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile --help missing whoami identity route")
}
if !strings.Contains(cmd.Long, "config show / profile list") {
t.Errorf("profile --help missing saved-config boundary")
}
const precedence = "A selected profile takes precedence over matching direct env credentials and tokens."
if !strings.Contains(cmd.Long, precedence) {
t.Errorf("profile --help missing precedence statement %q", precedence)
}
}
func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
cmd := NewCmdProfileList(nil)
if !strings.Contains(cmd.Short, "saved profiles") {
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
}
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
t.Errorf("profile list help missing whoami route")
}
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {

View File

@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
@@ -34,15 +33,6 @@ type whoamiResult struct {
TokenStatus string `json:"tokenStatus"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
// credential.IdentitySelection computed during resolution (not re-inferred
// here). On the non-env extension-provider path CredentialSource is
// "extension:<provider>" (e.g. "extension:sidecar"); an empty value only
// means the selection was never resolved.
CredentialSource string `json:"credentialSource"`
Explicit bool `json:"explicit"`
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
}
// delegatedUser is the user a user-identity acts on behalf of.
@@ -68,10 +58,6 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
The JSON output includes credentialSource, appId, brand, and whether direct app credential
env is present and matches the selected profile.`,
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
@@ -111,17 +97,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
// Read the cached selection computed during resolution; never re-infer it
// here. A resolution failure (e.g. under a non-env extension provider that
// doesn't populate a selection) degrades to the zero value rather than
// regressing whoami's own error/diagnostic path above.
var selection credential.IdentitySelection
if f.Credential != nil {
if sel, err := f.Credential.Selection(ctx); err == nil {
selection = sel
}
}
res := buildResult(cfg, as, source, diag, selection)
res := buildResult(cfg, as, source, diag)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
@@ -146,23 +122,18 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
// selection is the cached credential.IdentitySelection from resolution; it is
// read as-is, never recomputed.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
CredentialSource: string(selection.Source),
Explicit: selection.Explicit(),
DirectCredentialEnv: selection.DirectCredentialEnv,
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.

View File

@@ -15,13 +15,10 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/keychain"
)
func TestResolveSource(t *testing.T) {
@@ -55,7 +52,7 @@ func TestBuildResult_UserValid(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -80,7 +77,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -103,7 +100,7 @@ func TestBuildResult_BotReady(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
@@ -124,7 +121,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -321,94 +318,3 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
// plaintext secret, so no keychain lookup is actually required.
type noopWhoamiKeychain struct{}
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
// credentialSourceSecret is the profile secret written to config for
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
// (security: never leak a secret).
const credentialSourceSecret = "test-secret"
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
// fallback (not --profile), so Selection().Source resolves to
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
// app-credential env vars present.
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret(credentialSourceSecret),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
cred.WithProfileFromEnv("tenant_a")
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
// from the cached credential.IdentitySelection: credentialSource,
// explicit, and directCredentialEnv. whoami must read the cached selection
// as-is, not re-infer it.
func TestWhoamiIncludesCredentialSource(t *testing.T) {
f, out := profileSelectionFactory(t)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw := out.String()
if strings.Contains(raw, credentialSourceSecret) {
t.Fatalf("whoami output leaked the profile secret: %s", raw)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
}
if got.CredentialSource != string(credential.SourceEnvProfile) {
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
}
if !got.Explicit {
t.Fatalf("explicit = false, want true")
}
if got.DirectCredentialEnv.Present {
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
}
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
}
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
}
}

View File

@@ -67,17 +67,6 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
Credential/identity-selection extension fields (per-Subtype-stable):
| Field | Carrier | Subtypes | Notes |
|-------|---------|----------|-------|
| `missing_keys` | `ConfigError` | `app_credential_incomplete` | env var NAMES that must all be set; never values |
| `required_any_of` | `ConfigError` | `app_credential_incomplete` | env var NAMES where any one completes the credential; mutually exclusive with `missing_keys` |
| `profile` | `ConfigError` | `profile_not_found`, `profile_secret_invalid` | requested profile name |
| `app_id` | `ConfigError` | `profile_secret_invalid` | plaintext app id; never a secret |
| `credential_source` | `ConfigError` | `profile_not_found`, `no_active_profile` | how the identity was (not) chosen: `flag:--profile` \| `env:LARKSUITE_CLI_PROFILE` \| `config` |
| `profile_app_id`, `env_app_id` | `ValidationError` | `profile_app_credential_conflict` | the two conflicting plaintext app ids |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via

View File

@@ -136,79 +136,6 @@ func TestConfigError_MarshalJSON(t *testing.T) {
}
}
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
WithRequiredAnyOf("LARKSUITE_CLI_APP_SECRET", "LARKSUITE_CLI_USER_ACCESS_TOKEN").
WithProfile("work").
WithAppID("cli_abc").
WithCredentialSource("flag:--profile")
b, err := json.Marshal(ce)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"config"`,
`"subtype":"app_credential_incomplete"`,
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
`"required_any_of":["LARKSUITE_CLI_APP_SECRET","LARKSUITE_CLI_USER_ACCESS_TOKEN"]`,
`"profile":"work"`,
`"app_id":"cli_abc"`,
`"credential_source":"flag:--profile"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset fields must not appear on the wire.
empty := NewConfigError(SubtypeProfileNotFound, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"missing_keys"`, `"required_any_of"`, `"profile"`, `"app_id"`, `"credential_source"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
b, err := json.Marshal(ve)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"validation"`,
`"subtype":"profile_app_credential_conflict"`,
`"profile_app_id":"cli_profile"`,
`"env_app_id":"cli_env"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// omitempty: unset conflict fields must not appear on the wire.
empty := NewValidationError(SubtypeInvalidArgument, "x")
b2, err := json.Marshal(empty)
if err != nil {
t.Fatal(err)
}
s2 := string(b2)
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
if strings.Contains(s2, notWant) {
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
}
}
}
func TestNetworkError_MarshalJSON(t *testing.T) {
ne := &NetworkError{
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},

View File

@@ -12,9 +12,8 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
)
// CategoryAuthentication subtypes
@@ -42,13 +41,9 @@ const (
// CategoryConfig subtypes
const (
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
)
// CategoryNetwork subtypes

View File

@@ -61,11 +61,9 @@ type TypedError interface {
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
ProfileAppID string `json:"profile_app_id,omitempty"`
EnvAppID string `json:"env_app_id,omitempty"`
Cause error `json:"-"`
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"`
}
// InvalidParam is one structured validation diagnostic: the parameter that
@@ -152,12 +150,6 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
return e
}
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
e.ProfileAppID = profileAppID
e.EnvAppID = envAppID
return e
}
// =========================== AuthenticationError =============================
// AuthenticationError is the typed error for CategoryAuthentication.
@@ -323,18 +315,8 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
// intentionally not serialized.
type ConfigError struct {
Problem
Field string `json:"field,omitempty"`
MissingKeys []string `json:"missing_keys,omitempty"`
RequiredAnyOf []string `json:"required_any_of,omitempty"`
Profile string `json:"profile,omitempty"`
AppID string `json:"app_id,omitempty"`
// CredentialSource is the machine-readable App/credential selection source
// that produced this config error (e.g. "flag:--profile",
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
// profile_not_found and no_active_profile so an agent can branch
// on how the identity was (or was not) chosen. It is never a secret.
CredentialSource string `json:"credential_source,omitempty"`
Cause error `json:"-"`
Field string `json:"field,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
@@ -388,34 +370,6 @@ func (e *ConfigError) WithField(field string) *ConfigError {
return e
}
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
e.MissingKeys = slices.Clone(keys)
return e
}
func (e *ConfigError) WithRequiredAnyOf(keys ...string) *ConfigError {
e.RequiredAnyOf = slices.Clone(keys)
return e
}
func (e *ConfigError) WithProfile(name string) *ConfigError {
e.Profile = name
return e
}
func (e *ConfigError) WithAppID(appID string) *ConfigError {
e.AppID = appID
return e
}
// WithCredentialSource records the machine-readable credential-selection source
// on the wire (snake_case credential_source). The value is an enum string
// (e.g. "flag:--profile", "config"), never a secret.
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
e.CredentialSource = source
return e
}
func (e *ConfigError) WithCause(cause error) *ConfigError {
e.Cause = cause
return e

View File

@@ -643,29 +643,3 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
}
})
}
// ======================= Profile selection error subtypes =======================
func TestConfigErrorProfileFields(t *testing.T) {
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
WithMissingKeys("LARKSUITE_CLI_APP_ID").
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
p, ok := errs.ProblemOf(e)
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
t.Fatalf("subtype mismatch: %+v", p)
}
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
t.Errorf("missing_keys not set: %v", e.MissingKeys)
}
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
t.Errorf("credential_source not set: %q", e.CredentialSource)
}
}
func TestValidationErrorProfileConflict(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
WithProfileAppConflict("cli_profile", "cli_env")
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
}
}

View File

@@ -23,89 +23,63 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
appSecret := os.Getenv(envvars.CliAppSecret)
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
presentKeys := presentCredentialEnvKeys(appID, appSecret, hasUAT, hasTAT)
if len(presentKeys) == 0 {
return nil, nil
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
case hasTAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
default:
return nil, nil
}
}
if appID == "" {
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, &credential.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
// Identity policy variables are validated whenever a direct credential
// input is present. Their errors must not be hidden by a later credential
// completeness check or profile arbitration.
defaultAs := credential.Identity(os.Getenv(envvars.CliDefaultAs))
switch defaultAs {
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, defaultAs),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
strictMode := os.Getenv(envvars.CliStrictMode)
var supported credential.IdentitySupport
switch strictMode {
// Explicit strict mode policy takes priority
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
supported = credential.SupportsBot
acct.SupportedIdentities = credential.SupportsBot
case "user":
supported = credential.SupportsUser
acct.SupportedIdentities = credential.SupportsUser
case "off":
supported = credential.SupportsAll
acct.SupportedIdentities = credential.SupportsAll
case "":
// Infer from available tokens
if hasUAT {
supported |= credential.SupportsUser
acct.SupportedIdentities |= credential.SupportsUser
}
if hasTAT {
supported |= credential.SupportsBot
acct.SupportedIdentities |= credential.SupportsBot
}
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliStrictMode,
}
}
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, incompleteCredentialError(
appID,
envvars.CliUserAccessToken+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
case hasTAT:
return nil, incompleteCredentialError(
appID,
envvars.CliTenantAccessToken+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
}
}
if appID == "" {
return nil, incompleteCredentialError(
appID,
envvars.CliAppSecret+" is set but "+envvars.CliAppID+" is missing",
[]string{envvars.CliAppID}, nil, presentKeys)
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, incompleteCredentialError(
appID,
envvars.CliAppID+" is set but no app secret or access token is available",
nil,
[]string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
presentKeys)
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{
AppID: appID,
AppSecret: appSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: supported,
Kind: credential.AccountDirect,
}
if acct.DefaultAs == "" {
switch {
case hasUAT:
@@ -118,35 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return acct, nil
}
func incompleteCredentialError(appID, reason string, missingKeys, requiredAnyOf, presentKeys []string) *credential.BlockError {
return &credential.BlockError{
Provider: "env",
Reason: reason,
Code: credential.BlockReasonCredentialIncomplete,
MissingKeys: missingKeys,
RequiredAnyOf: requiredAnyOf,
PresentKeys: presentKeys,
AppID: appID,
}
}
func presentCredentialEnvKeys(appID, appSecret string, hasUAT, hasTAT bool) []string {
var keys []string
if appID != "" {
keys = append(keys, envvars.CliAppID)
}
if appSecret != "" {
keys = append(keys, envvars.CliAppSecret)
}
if hasUAT {
keys = append(keys, envvars.CliUserAccessToken)
}
if hasTAT {
keys = append(keys, envvars.CliTenantAccessToken)
}
return keys
}
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
var envKey string
switch req.Type {

View File

@@ -6,7 +6,6 @@ package env
import (
"context"
"errors"
"slices"
"strings"
"testing"
@@ -48,22 +47,6 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonCredentialIncomplete)
}
want := []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken}
if !slices.Equal(blockErr.RequiredAnyOf, want) {
t.Fatalf("RequiredAnyOf = %v, want %v", blockErr.RequiredAnyOf, want)
}
if len(blockErr.MissingKeys) != 0 {
t.Fatalf("MissingKeys = %v, want empty", blockErr.MissingKeys)
}
if !slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppID}) {
t.Fatalf("PresentKeys = %v, want [%s]", blockErr.PresentKeys, envvars.CliAppID)
}
if blockErr.AppID != "cli_test" {
t.Fatalf("AppID = %q, want cli_test", blockErr.AppID)
}
}
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
@@ -92,81 +75,18 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
!slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppSecret}) {
t.Fatalf("BlockError = %+v, want incomplete with missing APP_ID and present APP_SECRET", blockErr)
}
if len(blockErr.RequiredAnyOf) != 0 {
t.Fatalf("RequiredAnyOf = %v, want empty for APP_SECRET-only", blockErr.RequiredAnyOf)
}
}
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "UAT", key: envvars.CliUserAccessToken},
{name: "TAT", key: envvars.CliTenantAccessToken},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(tt.key, "token_test")
t.Setenv(envvars.CliUserAccessToken, "uat_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
}
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
!slices.Equal(blockErr.PresentKeys, []string{tt.key}) {
t.Fatalf("BlockError = %+v, want incomplete for %s", blockErr, tt.key)
}
if len(blockErr.RequiredAnyOf) != 0 {
t.Fatalf("RequiredAnyOf = %v, want empty for %s-only", blockErr.RequiredAnyOf, tt.name)
}
})
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
}
func TestResolveAccount_InvalidPolicyRejectedBeforeIncomplete(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "DEFAULT_AS", key: envvars.CliDefaultAs},
{name: "STRICT_MODE", key: envvars.CliStrictMode},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(tt.key, "banana")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("error = %T %v, want BlockError", err, err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
}
if blockErr.Param != tt.key {
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
}
if !strings.Contains(blockErr.Reason, tt.key) {
t.Fatalf("reason = %q, want %s", blockErr.Reason, tt.key)
}
})
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
}
}
@@ -338,9 +258,6 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliStrictMode {
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliStrictMode)
}
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
}
@@ -359,9 +276,6 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliDefaultAs {
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliDefaultAs)
}
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
}

View File

@@ -77,8 +77,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
}
}
@@ -94,8 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
Code: credential.BlockReasonInvalidPolicy,
Param: envvars.CliStrictMode,
}
}

View File

@@ -7,9 +7,7 @@ package sidecar
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -148,57 +146,6 @@ func TestResolveAccount_StrictMode(t *testing.T) {
}
}
func TestResolveAccount_InvalidPolicyClassified(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
tests := []struct {
name string
key string
value string
supportedText string
}{
{
name: "default as",
key: envvars.CliDefaultAs,
value: "banana",
supportedText: "want user, bot, or auto",
},
{
name: "strict mode",
key: envvars.CliStrictMode,
value: "banana",
supportedText: "want bot, user, or off",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
setEnv(t, tt.key, tt.value)
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("error = %T %v, want BlockError", err, err)
}
if blockErr.Code != credential.BlockReasonInvalidPolicy {
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
}
if blockErr.Param != tt.key {
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
}
if !strings.Contains(blockErr.Reason, tt.key) ||
!strings.Contains(blockErr.Reason, tt.value) ||
!strings.Contains(blockErr.Reason, tt.supportedText) {
t.Fatalf("Reason = %q, want variable, invalid value, and supported values", blockErr.Reason)
}
})
}
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)

View File

@@ -44,27 +44,6 @@ func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
// BotOnly returns true if only bot identity is supported.
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
// AccountKind declares how an account participates in credential arbitration.
type AccountKind int
const (
// AccountManaged means the provider owns the whole identity; winning it
// ends arbitration outright. The zero value, so existing providers are
// unchanged.
AccountManaged AccountKind = iota
// AccountDirect marks an actively supplied raw credential (the env
// provider's LARKSUITE_CLI_* variables). It participates in profile
// arbitration and conflict detection instead of winning outright.
//
// RESERVED: only the builtin env provider may declare AccountDirect
// today — the arbitration's direct-credential diagnostics are defined in
// terms of the process environment, and the caller rejects AccountDirect
// from any other provider. Third-party providers must return
// AccountManaged until the SPI carries provider-reported input
// descriptors.
AccountDirect
)
// Account holds resolved app credentials and configuration.
type Account struct {
AppID string
@@ -74,7 +53,6 @@ type Account struct {
ProfileName string
OpenID string // optional; if UAT is available, API result takes precedence
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
Kind AccountKind // AccountManaged (default) or AccountDirect
}
// Token holds a resolved access token and optional metadata.
@@ -98,38 +76,11 @@ type TokenSpec struct {
AppID string
}
// BlockReason classifies provider-originated block conditions that callers may
// safely map to a more specific public error contract.
type BlockReason string
const (
// BlockReasonCredentialIncomplete marks incomplete inputs from the builtin
// process-env credential provider. It is reserved for that provider because
// direct-credential arbitration and diagnostics currently name the fixed
// LARKSUITE_CLI_* env surface. Third-party providers must return an
// unclassified BlockError until the SPI carries provider-owned input
// descriptors. Blocks without a Code propagate unchanged.
BlockReasonCredentialIncomplete BlockReason = "credential_incomplete"
// BlockReasonInvalidPolicy marks a user-supplied policy input (e.g.
// LARKSUITE_CLI_DEFAULT_AS / LARKSUITE_CLI_STRICT_MODE) that failed
// validation. The caller maps it to a typed validation error carrying
// Param and a repair hint, so user input mistakes never surface as
// internal errors.
BlockReasonInvalidPolicy BlockReason = "invalid_policy"
)
// BlockError is returned by a Provider to actively reject a request
// and prevent subsequent providers in the chain from being consulted.
type BlockError struct {
Provider string
Reason string
Code BlockReason
MissingKeys []string // environment variable names only; never values
RequiredAnyOf []string // environment variable names only; never values
PresentKeys []string // environment variable names only; never values
AppID string // plaintext app identifier used only for source comparison; never a secret
Param string // name of the invalid input variable on invalid_policy blocks; never a value
Provider string
Reason string
}
func (e *BlockError) Error() string {

View File

@@ -48,18 +48,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
return &credential.TokenResult{Token: "test-token"}, nil
}
type clientTestAccountResolver struct {
appID string
}
func (r clientTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
}
func newClientTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, clientTestAccountResolver{appID: appID}, tokenResolver, nil)
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
@@ -70,7 +58,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpClient),
)
testCred := newClientTestCredentialProvider("test-app", &staticTokenResolver{})
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
return &APIClient{
SDK: sdk,
@@ -475,7 +463,7 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -510,7 +498,7 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
})
ac := &APIClient{
HTTP: &http.Client{Transport: rt},
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -544,7 +532,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -584,7 +572,7 @@ func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.Tok
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: newClientTestCredentialProvider("test-app", &needAuthTokenResolver{userOpenID: "ou_test_user"}),
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
@@ -624,7 +612,7 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}

View File

@@ -27,11 +27,6 @@ import (
// In tests, replace any field to stub out external dependencies.
type InvocationContext struct {
Profile string
// ProfileFromFlag is true when Profile was set via the --profile flag,
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
// (or neither was set). Downstream credential resolution uses this to
// report the correct profile source.
ProfileFromFlag bool
}
type Factory struct {

View File

@@ -63,11 +63,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// 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,
ProfileFromFlag: inv.ProfileFromFlag,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Runtime config contains resolved account data only.
@@ -175,11 +174,10 @@ func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
ProfileFromFlag bool
HttpClient func() (*http.Client, error)
ErrOut io.Writer
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
@@ -192,13 +190,5 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// depend on. enrichUserInfo failures are already non-fatal (the
// provider clears unverified identity fields), so silencing the
// warning is safe.
cred := credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
if deps.Profile == "" {
// No profile selected — don't record a phantom env source.
return cred
}
if deps.ProfileFromFlag {
return cred.WithProfileFromFlag(deps.Profile)
}
return cred.WithProfileFromEnv(deps.Profile)
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
@@ -406,14 +405,6 @@ type stubExtProvider struct {
err error
}
type stubDefaultAccountResolver struct {
acct *credential.Account
}
func (s *stubDefaultAccountResolver) ResolveAccount(_ context.Context) (*credential.Account, error) {
return s.acct, nil
}
func (s *stubExtProvider) Name() string { return s.name }
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
return s.acct, s.err
@@ -457,86 +448,6 @@ func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
}
}
func TestRequireBuiltinCredentialProvider_AllowsMatchingAppIDOnlyProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "cli_a")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{
Name: "tenant_a",
AppId: "cli_a",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
&stubDefaultAccountResolver{acct: &credential.Account{AppID: "cli_a", AppSecret: "test-secret"}},
nil,
nil,
).WithProfileFromFlag("tenant_a")
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
if err := f.RequireBuiltinCredentialProvider(context.Background(), "auth"); err != nil {
t.Fatalf("matching APP_ID-only profile should use builtin credentials: %v", err)
}
}
// A stale LARKSUITE_CLI_PROFILE (profile that cannot resolve) must not lock
// the user out of the builtin setup/repair commands this gate guards: the
// probe falls back to provider engagement and lets the command run.
func TestRequireBuiltinCredentialProvider_StaleProfileDoesNotLockOut(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // no config -> "ghost" cannot resolve
stub := &stubExtProvider{name: "env"} // not engaged: returns nil, nil
cred := credential.NewCredentialProvider(
[]extcred.Provider{stub},
&stubDefaultAccountResolver{},
nil,
nil,
).WithProfileFromEnv("ghost")
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
if err := f.RequireBuiltinCredentialProvider(context.Background(), "config"); err != nil {
t.Fatalf("stale profile must not lock out builtin auth/config commands: %v", err)
}
}
// An invalid policy variable (e.g. LARKSUITE_CLI_DEFAULT_AS=banana) is a user
// input error, not an external credential takeover: the gate surfaces the
// same typed validation error as formal arbitration instead of a misleading
// "provided externally" refusal.
func TestRequireBuiltinCredentialProvider_InvalidPolicySurfacesTypedError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
stub := &stubExtProvider{name: "env", err: &extcred.BlockError{
Provider: "env",
Reason: "invalid LARKSUITE_CLI_DEFAULT_AS \"banana\" (want user, bot, or auto)",
Code: extcred.BlockReasonInvalidPolicy,
Param: envvars.CliDefaultAs,
}}
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, &stubDefaultAccountResolver{}, nil, nil)
f, _, _, _ := TestFactory(t, nil)
f.Credential = cred
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("err = %v, want typed invalid_argument (same as formal arbitration)", err)
}
if strings.Contains(err.Error(), "provided externally") {
t.Fatalf("err = %v, must not read as external takeover", err)
}
}
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, nil)
f.Credential = 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

@@ -255,11 +255,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
// invalid_config, not not_configured: the config exists but is
// internally inconsistent. not_configured would let callers degrade
// this into a generic "secret invalid" answer and destroy the precise
// repair hint (which names the expected keychain key — never a value).
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "appId and appSecret keychain key are out of sync").
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}

View File

@@ -36,13 +36,16 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
if errors.Is(err, os.ErrNotExist) {
return nil, NotConfiguredError()
}
// Surface the real cause so the user can fix the broken file. Every
// non-ENOENT load failure — malformed JSON, permission denied, I/O
// error — means a config EXISTS but cannot be used: invalid_config.
// Only a genuinely absent config is not_configured; anything else
// classified as not_configured would let callers degrade it into
// profile_not_found / no_active_profile and hide the real cause.
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
// Surface the real cause (parse error, permission denied, etc.)
// so the user can fix the broken file. A malformed file is
// invalid_config; anything else (permission denied, etc.) is
// not_configured. Both stay on the typed structured-envelope path
// at the root command's error sink.
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
return nil, NotConfiguredError()

View File

@@ -1,154 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package credential_test
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
sidecarprovider "github.com/larksuite/cli/extension/credential/sidecar"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/sidecar"
)
func newRealSidecarCredentialProvider(t *testing.T) *credential.CredentialProvider {
t.Helper()
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv(envvars.CliProxyKey, "test-key")
t.Setenv(envvars.CliAppID, "cli_sidecar")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return credential.NewCredentialProvider(
[]extcred.Provider{&sidecarprovider.Provider{}},
nil,
nil,
nil,
)
}
func TestAuthSidecarInvalidPolicyUsesValidationContract(t *testing.T) {
for _, tt := range []struct {
name string
key string
}{
{name: "default as", key: envvars.CliDefaultAs},
{name: "strict mode", key: envvars.CliStrictMode},
} {
t.Run(tt.name, func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
t.Setenv(tt.key, "banana")
_, err := cp.ResolveAccount(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed validation error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if validationErr.Param != tt.key {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.key)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(problem.Hint, tt.key) {
t.Fatalf("hint = %q, want variable name %s", problem.Hint, tt.key)
}
var blockErr *extcred.BlockError
if !errors.As(err, &blockErr) ||
blockErr.Code != extcred.BlockReasonInvalidPolicy ||
blockErr.Param != tt.key {
t.Fatalf("cause = %T %v, want classified BlockError for %s", err, err, tt.key)
}
})
}
}
func TestAuthSidecarGateProbeUsesValidationContract(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
t.Setenv(envvars.CliStrictMode, "banana")
name, err := cp.ActiveExtensionProviderName(context.Background())
if name != "" {
t.Fatalf("provider name = %q, want empty on invalid policy", name)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed validation error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if problem.Category != errs.CategoryValidation ||
problem.Subtype != errs.SubtypeInvalidArgument ||
validationErr.Param != envvars.CliStrictMode {
t.Fatalf("problem = %+v param = %q, want validation/invalid_argument param %s", problem, validationErr.Param, envvars.CliStrictMode)
}
}
func TestAuthSidecarTokenHonorsSelectedAppID(t *testing.T) {
t.Run("matching app returns sentinel", func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: "cli_sidecar",
})
if err != nil {
t.Fatalf("ResolveToken: %v", err)
}
if result == nil || result.Token != sidecar.SentinelUAT {
t.Fatalf("result = %+v, want sidecar UAT sentinel", result)
}
})
for _, tt := range []struct {
name string
appID string
}{
{name: "empty app id", appID: ""},
{name: "conflicting app id", appID: "cli_other"},
} {
t.Run(tt.name, func(t *testing.T) {
cp := newRealSidecarCredentialProvider(t)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: tt.appID,
})
if result != nil {
t.Fatalf("result = %+v, want no sidecar sentinel", result)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed internal error", err, err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown)
}
if strings.Contains(err.Error(), sidecar.SentinelUAT) {
t.Fatalf("error leaked sidecar sentinel: %v", err)
}
})
}
}

View File

@@ -9,17 +9,11 @@ import (
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"sync"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// DefaultAccountResolver is implemented by the default account provider.
@@ -142,21 +136,10 @@ type CredentialProvider struct {
httpClient func() (*http.Client, error)
warnOut io.Writer
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE);
// profileSrc records which of the two supplied it, for the reported
// selection and error attribution.
profile string
profileSrc CredentialSourceKind
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
// selection is the explainable credential-selection result, populated by
// doResolveAccount under accountOnce. It never carries a secret.
selection IdentitySelection
enrichOnce sync.Once
hintOnce sync.Once
hint *IdentityHint
@@ -178,521 +161,49 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
return p
}
// WithProfileFromFlag records the --profile flag value as the active profile.
// It governs credential arbitration and the reported selection source.
func (p *CredentialProvider) WithProfileFromFlag(profile string) *CredentialProvider {
p.profile = profile
p.profileSrc = SourceFlagProfile
return p
}
// WithProfileFromEnv records the LARKSUITE_CLI_PROFILE env fallback as the
// active profile. It governs credential arbitration and the reported
// selection source.
func (p *CredentialProvider) WithProfileFromEnv(profile string) *CredentialProvider {
p.profile = profile
p.profileSrc = SourceEnvProfile
return p
}
// ResolveAccount resolves app credentials. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
// Subsequent calls return the cached result regardless of their context.
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
acct, err := p.resolveAccountSelection(ctx)
if err != nil || acct == nil {
return acct, err
}
if _, ok := p.selectedSource.(extensionTokenSource); ok {
p.enrichOnce.Do(func() {
p.enrichOrClearIdentity(ctx, acct, p.selectedSource)
})
}
return acct, nil
}
// resolveAccountSelection performs and caches only credential selection. It
// deliberately does not resolve tokens or user_info, so callers can validate
// the selected app before any token work begins.
func (p *CredentialProvider) resolveAccountSelection(ctx context.Context) (*Account, error) {
p.accountOnce.Do(func() {
p.account, p.accountErr = p.doResolveAccount(ctx)
})
return p.account, p.accountErr
}
// doResolveAccount arbitrates the credential/App selection in three phases:
// gather all arbitration inputs in a single I/O pass, decide the route with a
// pure function, then execute the remaining I/O for the chosen route.
//
// Resolution order (encoded in decideIdentity): a managed extension provider
// (e.g. sidecar) wins outright; then an explicit profile (--profile /
// LARKSUITE_CLI_PROFILE) arbitrates against the direct env credential
// (matching app_id → profile supplies credential and tokens; mismatch → hard
// conflict; incomplete env without a usable app_id → repair error); then a
// complete direct env credential; then the config default (currentApp →
// firstApp).
//
// It populates p.selection (never carries a secret) and p.selectedSource on
// every success path.
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
in, err := p.gatherIdentityInputs(ctx)
if err != nil {
return nil, err
}
d, err := decideIdentity(in)
if err != nil {
return nil, err
}
acct, source, err := p.execute(ctx, d, in)
if err != nil {
return nil, err
}
p.selectedSource = source
// Assigned only after full success: error paths can never leave a
// partial selection behind.
p.selection = d.selection
return acct, nil
}
// providerAccount pairs an extension-provider account with its token source.
type providerAccount struct {
acct *Account
source extensionTokenSource
}
// identityInputs is one invocation's complete arbitration input, gathered in
// a single pass by gatherIdentityInputs. It is read-only after gathering;
// decideIdentity consumes it without further I/O.
type identityInputs struct {
profile string
profileSrc CredentialSourceKind
managed *providerAccount // managed extension account; wins arbitration outright
direct *providerAccount // complete direct env credential
// directBlock is a provider's explicit incomplete-direct-credential
// classification (BlockError.Code == credential_incomplete). It
// participates in profile arbitration instead of failing outright.
directBlock *extcred.BlockError
// directKeys / conflictKeys describe the BUILTIN process-env direct
// credential surface (LARKSUITE_CLI_* variable NAMES, never values).
// They annotate DirectCredentialEnv and conflict hints; a third-party
// AccountDirect provider reports its own inputs via BlockError metadata
// (PresentKeys/AppID), not through these.
directKeys []string
conflictKeys []string
config *core.MultiAppConfig
configErr error
}
// gatherIdentityInputs performs the arbitration's read phase: it consults the
// extension providers and snapshots the config. Providers classify their own
// failures at the source (BlockError.Code); this layer must not infer them by
// re-reading environment variables or parsing Reason.
func (p *CredentialProvider) gatherIdentityInputs(ctx context.Context) (identityInputs, error) {
in := identityInputs{
profile: p.profile,
profileSrc: p.profileSrc,
directKeys: presentDirectCredentialKeys(),
conflictKeys: presentDirectCredentialInputKeys(),
}
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
switch blockErr.Code {
case extcred.BlockReasonCredentialIncomplete:
// app_credential_incomplete, profile matching, and
// DirectCredentialEnv diagnostics are defined in terms of
// the builtin LARKSUITE_CLI_* env surface. Until the SPI
// carries provider-owned input descriptors, accepting this
// classification from another provider would produce
// contradictory arbitration and repair hints.
if _, builtin := prov.(*envprovider.Provider); !builtin {
return in, newCredentialIncompleteProviderContractError(prov)
}
in.directBlock = blockErr
case extcred.BlockReasonInvalidPolicy:
// A user-supplied policy value failed validation; that is
// a validation error, never an internal one.
return in, newInvalidPolicyError(blockErr)
default:
// Blocks without a recognized Code preserve their
// original attribution.
return in, err
return nil, err
}
if acct != nil {
internal := convertAccount(acct)
source := extensionTokenSource{provider: prov}
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
break
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
}
// Any other provider error preserves its original attribution.
return in, err
}
if acct == nil {
continue
}
pa := &providerAccount{acct: convertAccount(acct), source: extensionTokenSource{provider: prov}}
switch acct.Kind {
case extcred.AccountDirect:
// The arbitration's direct-credential surface — DirectCredentialEnv,
// the env:LARKSUITE_CLI_APP_ID selection source, conflict-hint
// keys — is defined in terms of the builtin process-env variables.
// Until the SPI carries provider-reported input descriptors, only
// the builtin env provider may declare AccountDirect; accepting it
// from anyone else would produce self-contradictory diagnostics
// (e.g. credentialSource "env:LARKSUITE_CLI_APP_ID" with
// directCredentialEnv.present=false). The check is by concrete
// type: the registry reserves neither names nor uniqueness, so a
// Name() comparison would be forgeable.
if _, builtin := prov.(*envprovider.Provider); !builtin {
return in, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q declared AccountDirect, which is reserved for the builtin env provider", prov.Name())
}
in.direct = pa
case extcred.AccountManaged:
in.managed = pa
default:
return in, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q returned unknown AccountKind %d", prov.Name(), acct.Kind)
}
break // the first engaged provider ends the scan (registry priority order)
}
// The config snapshot backs profile lookup, the config-default route, and
// config-default failure attribution. A winning managed or direct-env
// identity without a profile never needs it — and managed identities must
// keep working when the config is absent or malformed.
if in.managed == nil && (in.profile != "" || in.direct == nil) {
in.config, in.configErr = core.LoadOrNotConfigured()
}
return in, nil
}
// credentialRoute names which source serves the selected account and tokens.
type credentialRoute int
const (
routeManaged credentialRoute = iota
routeProfile
routeDirectEnv
routeConfigDefault
)
// decision is decideIdentity's complete verdict. Nothing in it touched I/O.
type decision struct {
route credentialRoute
selection IdentitySelection
// profileAppID is set on routeProfile; app_id is plaintext and safe to
// echo in the secret-invalid error.
profileAppID string
}
// decideIdentity holds every selection rule in one place: precedence
// (managed > profile > direct env > config default), profile/direct-env
// conflict detection, and error attribution. It is pure — same inputs, same
// verdict — so the full selection matrix is table-testable without env vars
// or config fixtures.
func decideIdentity(in identityInputs) (decision, error) {
// DirectCredentialEnv reports the direct env vars truthfully on every
// route: Present always means "direct credential env vars are set".
directEnv := DirectCredentialEnv{Present: len(in.directKeys) > 0, Keys: in.directKeys}
if in.direct != nil {
directEnv.AppID = in.direct.acct.AppID
}
switch {
case in.managed != nil:
return decision{route: routeManaged, selection: IdentitySelection{
Source: SourceExtension(in.managed.source.Name()),
DirectCredentialEnv: directEnv,
}}, nil
case in.profile != "":
return decideProfile(in, directEnv)
case in.directBlock != nil:
return decision{}, newAppCredentialIncompleteError(in.directBlock, false)
case in.direct != nil:
return decision{route: routeDirectEnv, selection: IdentitySelection{
Source: SourceEnvAppID,
DirectCredentialEnv: directEnv,
}}, nil
default:
return decision{route: routeConfigDefault, selection: IdentitySelection{
Source: selectionSourceForDefault(in.config),
DirectCredentialEnv: directEnv,
}}, nil
}
}
// decideProfile arbitrates an explicit profile against the direct env
// credential state.
func decideProfile(in identityInputs, directEnv DirectCredentialEnv) (decision, error) {
app, err := findProfile(in)
if err != nil {
return decision{}, err
}
if in.directBlock != nil {
// APP_ID-only is sufficient to compare sources: a matching selected
// profile supplies the credential and tokens; a mismatch is the same
// hard conflict as a complete direct env. Anything less than a usable
// app_id keeps the provider's repair error, extended with the
// unset-to-use-the-profile path.
if in.directBlock.AppID == "" || !slices.Contains(in.directBlock.PresentKeys, envvars.CliAppID) {
return decision{}, newAppCredentialIncompleteError(in.directBlock, true)
}
if app.AppId != in.directBlock.AppID {
return decision{}, newProfileAppCredentialConflict(
in.profile, app.AppId, in.directBlock.AppID, in.directBlock.PresentKeys)
}
directEnv.AppID = in.directBlock.AppID
directEnv.Matched = true
}
if in.direct != nil {
// E == complete: the direct env app_id must match the profile.
if app.AppId != in.direct.acct.AppID {
return decision{}, newProfileAppCredentialConflict(
in.profile, app.AppId, in.direct.acct.AppID, in.conflictKeys)
}
directEnv.Matched = true
}
return decision{
route: routeProfile,
selection: IdentitySelection{Source: in.profileSrc, DirectCredentialEnv: directEnv},
profileAppID: app.AppId,
}, nil
}
// findProfile resolves the requested profile against the config snapshot.
// A malformed config must surface its real typed cause (invalid_config):
// reporting it as profile_not_found would send the user to `profile list`
// and hide the broken file. Only a genuinely absent config degrades to
// profile_not_found, because the profile then cannot exist anywhere. Both
// deliberately outrank an incomplete direct env: fixing the profile side is
// what makes the selected profile usable.
func findProfile(in identityInputs) (*core.AppConfig, error) {
if in.configErr != nil {
if prob, ok := errs.ProblemOf(in.configErr); !ok || prob.Subtype != errs.SubtypeNotConfigured {
return nil, in.configErr
p.selectedSource = source
return internal, nil
}
}
if in.config != nil {
if app := in.config.FindApp(in.profile); app != nil {
return app, nil
}
}
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
"profile %q not found", in.profile).
WithProfile(in.profile).
WithCredentialSource(string(in.profileSrc)).
WithHint("run `lark-cli profile list` to see available profiles.")
}
// execute performs the remaining I/O for the decided route and returns the
// account together with its token source.
func (p *CredentialProvider) execute(ctx context.Context, d decision, in identityInputs) (*Account, credentialSource, error) {
switch d.route {
case routeManaged:
return in.managed.acct, in.managed.source, nil
case routeDirectEnv:
return in.direct.acct, in.direct.source, nil
case routeProfile:
// Resolve the profile's own (keychain-backed) credential locally.
if p.defaultAcct != nil {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
// A typed failure other than not_configured carries its own
// precise, secret-free diagnosis (typed errors never embed secret
// material per the error contract) — pass it through instead of
// flattening it into the generic secret error. Untyped failures
// and a config that vanished mid-resolution stay masked: their
// content is not guaranteed secret-free.
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype != errs.SubtypeNotConfigured {
return nil, nil, err
}
return nil, nil, newProfileSecretInvalidError(in.profile, d.profileAppID)
return nil, err
}
// The resolver re-reads the config; a concurrent profile edit between
// gather and here could hand back a different app. Refuse the mismatch
// instead of silently using credentials the arbitration never checked.
if acct.AppID != d.profileAppID {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"config changed during resolution: profile %q resolved to a different app", in.profile).
WithHint("retry the command.")
}
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
default: // routeConfigDefault
if p.defaultAcct == nil {
return nil, nil, core.NotConfiguredError()
}
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, nil, translateConfigDefaultFailure(err, in.config)
}
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
}
// translateConfigDefaultFailure attributes a config-default failure from the
// snapshot: a default profile that EXISTS (has an app_id) but whose secret
// cannot be resolved locally is profile_secret_invalid — "identity is
// configured, its secret is broken" is more actionable than "no active
// profile". Only when there is genuinely no usable default profile do we
// report no_active_profile. Other typed failures pass through unchanged.
func translateConfigDefaultFailure(err error, multi *core.MultiAppConfig) error {
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeNotConfigured {
return err
}
if multi != nil {
if app := multi.CurrentAppConfig(""); app != nil && app.AppId != "" {
return newProfileSecretInvalidError(app.ProfileName(), app.AppId)
}
}
return errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
WithCredentialSource(noActiveProfileCredentialSource).
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
}
func newProfileAppCredentialConflict(profile, profileAppID, envAppID string, presentKeys []string) error {
err := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
"profile %q app_id does not match %s", profile, envvars.CliAppID).
WithProfileAppConflict(profileAppID, envAppID)
if len(presentKeys) > 0 {
return err.WithHint("unset %s, or select a profile whose app_id matches the environment.",
humanList(presentKeys, "and"))
}
return err.WithHint("unset the direct credential environment variables, or select a profile whose app_id matches the environment.")
}
func newAppCredentialIncompleteError(blockErr *extcred.BlockError, selectedProfileAvailable bool) *errs.ConfigError {
err := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "%s", blockErr.Reason).
WithCause(blockErr)
if len(blockErr.MissingKeys) > 0 {
err.WithMissingKeys(blockErr.MissingKeys...)
}
if len(blockErr.RequiredAnyOf) > 0 {
err.WithRequiredAnyOf(blockErr.RequiredAnyOf...)
}
hint := credentialRepairHint(blockErr)
if selectedProfileAvailable && len(blockErr.PresentKeys) > 0 {
hint += fmt.Sprintf(", or unset %s to use the selected profile", humanList(blockErr.PresentKeys, "and"))
}
return err.WithHint("%s.", hint)
}
func credentialRepairHint(blockErr *extcred.BlockError) string {
if len(blockErr.RequiredAnyOf) > 0 {
return "set " + humanList(blockErr.RequiredAnyOf, "or")
}
return "set " + humanList(blockErr.MissingKeys, "and")
}
func humanList(items []string, conjunction string) string {
switch len(items) {
case 0:
return "the missing direct credential variables"
case 1:
return items[0]
case 2:
return items[0] + " " + conjunction + " " + items[1]
default:
return strings.Join(items[:len(items)-1], ", ") + ", " + conjunction + " " + items[len(items)-1]
}
}
// newInvalidPolicyError translates a provider's invalid-policy block into the
// typed validation contract: the failed variable name travels in param, the
// repair path in the hint, and the original block stays on the cause chain.
// Reason carries only the variable name and its non-secret value.
func newInvalidPolicyError(blockErr *extcred.BlockError) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", blockErr.Reason).
WithParam(blockErr.Param).
WithCause(blockErr).
WithHint("set %s to a supported value or unset it.", blockErr.Param)
}
func newCredentialIncompleteProviderContractError(prov extcred.Provider) error {
return errs.NewInternalError(errs.SubtypeUnknown,
"credential provider %q returned credential_incomplete, which is reserved for the builtin env provider", prov.Name())
}
// newProfileSecretInvalidError is deliberately generic (SECURITY): the
// underlying cause may carry secret material, so neither it nor its message
// may reach the envelope. app_id is plaintext and safe to echo.
func newProfileSecretInvalidError(profile, appID string) error {
return errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
"profile %q credential could not be resolved locally", profile).
WithProfile(profile).
WithAppID(appID).
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
}
// enrichOrClearIdentity verifies a provider-supplied user identity via
// enrichUserInfo. Verification failure is non-fatal — SupportedIdentities
// (used for strict mode) is already set by the provider — but an unverified
// identity must not survive it: a stale OpenID would attribute calls to a
// user the token can no longer act for.
func (p *CredentialProvider) enrichOrClearIdentity(ctx context.Context, acct *Account, source credentialSource) {
err := p.enrichUserInfo(ctx, acct, source)
if err == nil {
return
}
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
acct.UserOpenId = ""
acct.UserName = ""
}
// noActiveProfileCredentialSource is the credential_source reported on the
// no_active_profile error. The error contract fixes this to the literal "config": there is
// no resolved default profile at all, so the more specific config:currentApp /
// config:firstApp source values (used on successful config-default selections)
// would be misleading. It is an enum string, never a secret.
const noActiveProfileCredentialSource = "config"
// selectionSourceForDefault reports whether the config default resolved to the
// explicit currentApp or fell back to the first app.
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
if multi != nil && multi.CurrentApp != "" {
return SourceConfigCurrentApp
}
return SourceConfigFirstApp
}
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
func presentDirectCredentialKeys() []string {
var keys []string
if os.Getenv(envvars.CliAppID) != "" {
keys = append(keys, envvars.CliAppID)
}
if os.Getenv(envvars.CliAppSecret) != "" {
keys = append(keys, envvars.CliAppSecret)
}
return keys
}
// presentDirectCredentialInputKeys returns all direct env input names that
// must be cleared together to remove a profile/app_id conflict. Values are
// never returned.
func presentDirectCredentialInputKeys() []string {
keys := presentDirectCredentialKeys()
if os.Getenv(envvars.CliUserAccessToken) != "" {
keys = append(keys, envvars.CliUserAccessToken)
}
if os.Getenv(envvars.CliTenantAccessToken) != "" {
keys = append(keys, envvars.CliTenantAccessToken)
}
return keys
}
// Selection resolves the account (once) and returns the cached, secret-free
// explanation of how the credential/App was selected. It mirrors
// selectedCredentialSource: resolve-then-return.
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
if _, err := p.ResolveAccount(ctx); err != nil {
return IdentitySelection{}, err
}
return p.selection, nil
return nil, core.NotConfiguredError()
}
// enrichUserInfo resolves user identity when extension provides a UAT.
@@ -728,13 +239,17 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
}
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
if _, err := p.resolveAccountSelection(ctx); err != nil {
if p.selectedSource != nil {
return p.selectedSource, nil
}
if p.defaultAcct == nil {
return nil, nil
}
if _, err := p.ResolveAccount(ctx); err != nil {
return nil, err
}
if p.selectedSource == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved an account without selecting a token source").
WithHint("retry the command.")
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
}
return p.selectedSource, nil
}
@@ -787,88 +302,51 @@ func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*Identi
// ResolveToken resolves an access token.
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
acct, err := p.resolveAccountSelection(ctx)
source, err := p.selectedCredentialSource(ctx)
if err != nil {
return nil, err
}
if acct == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved no account before %s token resolution", req.Type).
WithHint("retry the command.")
if source != nil {
return resolveTokenFromSource(ctx, source, req)
}
source := p.selectedSource
if source == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"credential provider resolved app %q without selecting a token source", acct.AppID).
WithHint("retry the command.")
for _, prov := range p.providers {
source := extensionTokenSource{provider: prov}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if found {
return result, nil
}
}
if req.AppID == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"TokenSpec.AppID is required for %s token resolution", req.Type).
WithHint("retry the command.")
source = defaultTokenSource{resolver: p.defaultToken}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if req.AppID != acct.AppID {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"token requested for app %q but the selected account belongs to app %q", req.AppID, acct.AppID).
WithHint("retry the command.")
if found {
return result, nil
}
return resolveTokenFromSource(ctx, source, req)
return nil, &TokenUnavailableError{Type: req.Type}
}
// ActiveExtensionProviderName reports whether an extension provider is managing
// the credentials that actually win selection. With an explicit profile that
// resolves successfully it reuses ResolveAccount's cached arbitration result;
// otherwise it probes extension providers directly and returns the first
// engaged provider.
// credentials. It probes p.providers (extension providers only, not defaultAcct)
// and returns the name of the first engaged provider.
//
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
// *extcred.BlockError (provider configured but misconfigured — still counts as
// external). Any other probe error is propagated to the caller.
//
// A failed profile resolution (profile not found, broken secret, malformed
// config, incomplete direct env, ...) deliberately does NOT propagate: this
// probe guards the builtin setup/repair commands (auth, config), and an
// unresolvable credential must never lock the user out of the commands that
// fix it. It falls back to the engagement probe, which answers the only
// question this function owns: is an extension provider holding credentials?
// external). Any other error is propagated to the caller.
//
// Returns ("", nil) when no extension provider is active (built-in keychain path).
// Safe to call multiple times: explicit-profile resolution uses sync.Once, while
// the probe path only consults providers.
// Safe to call multiple times — probes providers directly without the sync.Once cache.
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
// With an explicit profile, report the source that actually won the same
// arbitration used by commands. A matching APP_ID-only env block is not an
// external takeover once the selected profile supplies credentials/tokens.
if p.profile != "" {
if _, err := p.ResolveAccount(ctx); err == nil {
if p.selectedSource == nil {
return "", nil
}
if _, builtin := p.selectedSource.(defaultTokenSource); builtin {
return "", nil
}
return p.selectedSource.Name(), nil
}
// Resolution failed — fall through to the engagement probe.
}
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
// Align with formal arbitration: a misconfigured policy
// variable is the same typed validation error everywhere —
// not an external takeover of the provider that reported it,
// and not license to keep scanning and blame a later
// provider instead.
if blockErr.Code == extcred.BlockReasonInvalidPolicy {
return "", newInvalidPolicyError(blockErr)
}
if blockErr.Code == extcred.BlockReasonCredentialIncomplete {
if _, builtin := prov.(*envprovider.Provider); !builtin {
return "", newCredentialIncompleteProviderContractError(prov)
}
}
name := blockErr.Provider
if name == "" {
name = prov.Name()

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
@@ -24,7 +23,6 @@ type mockExtProvider struct {
err error
accountErr error
tokenErr error
tokenCalls int
}
func (m *mockExtProvider) Name() string { return m.name }
@@ -35,7 +33,6 @@ func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account,
return m.account, m.err
}
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
m.tokenCalls++
if m.tokenErr != nil {
return nil, m.tokenErr
}
@@ -52,13 +49,11 @@ func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error)
}
type mockDefaultToken struct {
result *TokenResult
err error
tokenCalls int
result *TokenResult
err error
}
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
m.tokenCalls++
return m.result, m.err
}
@@ -121,45 +116,35 @@ func TestCredentialProvider_AccountCached(t *testing.T) {
}
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
for _, sourceName := range []string{"env", "authsidecar"} {
t.Run(sourceName, func(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: sourceName,
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
}},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
}
})
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
}
}
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "skip"}},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken, nil,
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if result.Token != "default_tok" {
t.Errorf("expected default_tok, got %s", result.Token)
}
if defaultToken.tokenCalls != 1 {
t.Fatalf("default ResolveToken() calls = %d, want 1", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
@@ -174,7 +159,7 @@ func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t
t.Fatalf("ResolveAccount() error = %v", err)
}
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatalf("ResolveToken() error = %v", err)
}
@@ -196,7 +181,7 @@ func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t
t.Fatalf("ResolveAccount() error = %v", err)
}
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil {
t.Fatal("ResolveToken() error = nil, want unavailable error")
}
@@ -217,7 +202,7 @@ func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *test
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || err.Error() != "provider exploded" {
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
}
@@ -327,12 +312,12 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
nil,
&mockDefaultToken{result: &TokenResult{Token: ""}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || !strings.Contains(err.Error(), "empty token") {
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
}
@@ -425,189 +410,17 @@ func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerification
}
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{err: errors.New("config unavailable")},
defaultToken,
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || err.Error() != "config unavailable" {
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
}
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeExtensionIO(t *testing.T) {
tests := []struct {
name string
appID string
}{
{name: "empty app id"},
{name: "different app id", appID: "other_app"},
}
for _, tt := range tests {
for _, sourceName := range []string{"env", "authsidecar"} {
t.Run(tt.name+"/"+sourceName, func(t *testing.T) {
provider := &mockExtProvider{
name: sourceName,
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
}
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{provider},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("unexpected user_info call")
},
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
assertInternalUnknownWithRetryHint(t, err)
if provider.tokenCalls != 0 {
t.Fatalf("extension ResolveToken() calls = %d, want 0", provider.tokenCalls)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
}
})
}
}
}
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeDefaultIO(t *testing.T) {
tests := []struct {
name string
appID string
}{
{name: "empty app id"},
{name: "different app id", appID: "other_app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken,
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
assertInternalUnknownWithRetryHint(t, err)
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
})
}
}
func TestCredentialProvider_ResolveTokenRejectsNilAccountBeforeTokenIO(t *testing.T) {
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{},
defaultToken,
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "requested_app"})
if err == nil {
t.Fatal("ResolveToken() error = nil, want nil account error")
}
assertInternalUnknownWithRetryHint(t, err)
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenRejectsMissingSelectedSourceWithoutFallback(t *testing.T) {
extension := &mockExtProvider{
name: "env",
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
cp := NewCredentialProvider(
[]extcred.Provider{extension},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
defaultToken,
nil,
)
cp.account = &Account{AppID: "selected_app"}
cp.accountOnce.Do(func() {})
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "selected_app"})
if err == nil {
t.Fatal("ResolveToken() error = nil, want missing selected source error")
}
assertInternalUnknownWithRetryHint(t, err)
if extension.tokenCalls != 0 {
t.Fatalf("extension ResolveToken() calls = %d, want 0", extension.tokenCalls)
}
if defaultToken.tokenCalls != 0 {
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
}
}
func TestCredentialProvider_ResolveTokenMatchingExtensionDoesNotEnrichIdentity(t *testing.T) {
provider := &mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{provider},
nil,
nil,
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("unexpected user_info call")
},
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
if err != nil {
t.Fatalf("ResolveToken() error = %v", err)
}
if result.Token != "ext_tok" {
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "ext_tok")
}
if provider.tokenCalls != 1 {
t.Fatalf("extension ResolveToken() calls = %d, want 1", provider.tokenCalls)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
}
}
func assertInternalUnknownWithRetryHint(t *testing.T, err error) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed internal error", err)
}
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("error problem = %+v, want internal/unknown", problem)
}
if problem.Hint != "retry the command." {
t.Fatalf("error hint = %q, want retry hint", problem.Hint)
}
}
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {

View File

@@ -1,181 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// stubDecideProvider satisfies extcred.Provider for building providerAccount
// literals; decideIdentity only ever calls Name() on it.
type stubDecideProvider struct{ name string }
func (s stubDecideProvider) Name() string { return s.name }
func (s stubDecideProvider) Priority() int { return 0 }
func (s stubDecideProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return nil, nil
}
func (s stubDecideProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
func pa(providerName, appID string) *providerAccount {
return &providerAccount{
acct: &Account{AppID: appID},
source: extensionTokenSource{provider: stubDecideProvider{name: providerName}},
}
}
func appIDOnlyBlock(appID string) *extcred.BlockError {
return &extcred.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
Code: extcred.BlockReasonCredentialIncomplete,
RequiredAnyOf: []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
PresentKeys: []string{envvars.CliAppID},
AppID: appID,
}
}
func uatOnlyBlock() *extcred.BlockError {
return &extcred.BlockError{
Provider: "env",
Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing",
Code: extcred.BlockReasonCredentialIncomplete,
MissingKeys: []string{envvars.CliAppID},
PresentKeys: []string{envvars.CliUserAccessToken},
}
}
// TestDecideIdentity exercises the selection matrix as data: decideIdentity is
// pure, so every rule (precedence, conflict detection, error attribution) is
// table-testable without env vars or config fixtures.
func TestDecideIdentity(t *testing.T) {
tenantA := &core.MultiAppConfig{
CurrentApp: "tenant_a",
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
}
noCurrent := &core.MultiAppConfig{
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
}
invalidConfigErr := errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid config format")
notConfiguredErr := core.NotConfiguredError()
cases := []struct {
name string
in identityInputs
route credentialRoute
source CredentialSourceKind
matched bool
subtype errs.Subtype // "" = success expected
}{
{
name: "managed provider wins over explicit profile",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, managed: pa("sidecar", "sidecar_app"), config: tenantA},
route: routeManaged,
source: SourceExtension("sidecar"),
},
{
name: "profile conflicts with complete direct env app_id",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, direct: pa("env", "cli_x"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
subtype: errs.SubtypeProfileAppCredentialConflict,
},
{
name: "matched complete direct env yields profile route",
in: identityInputs{profile: "tenant_a", profileSrc: SourceEnvProfile, direct: pa("env", "cli_a"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
route: routeProfile,
source: SourceEnvProfile,
matched: true,
},
{
name: "APP_ID-only block matching the profile yields profile route",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
route: routeProfile,
source: SourceFlagProfile,
matched: true,
},
{
name: "APP_ID-only block mismatching the profile is a hard conflict",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_x"), directKeys: []string{envvars.CliAppID}, config: tenantA},
subtype: errs.SubtypeProfileAppCredentialConflict,
},
{
name: "UAT-only block with a valid profile keeps the repair error",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: uatOnlyBlock(), config: tenantA},
subtype: errs.SubtypeAppCredentialIncomplete,
},
{
name: "block without profile is app_credential_incomplete",
in: identityInputs{directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}},
subtype: errs.SubtypeAppCredentialIncomplete,
},
{
name: "complete direct env without profile wins",
in: identityInputs{direct: pa("env", "cli_env"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}},
route: routeDirectEnv,
source: SourceEnvAppID,
},
{
name: "malformed config is not masked as profile_not_found",
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, configErr: invalidConfigErr},
subtype: errs.SubtypeInvalidConfig,
},
{
name: "absent config degrades to profile_not_found",
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, configErr: notConfiguredErr},
subtype: errs.SubtypeProfileNotFound,
},
{
name: "profile missing from a valid config is profile_not_found even with incomplete env",
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
subtype: errs.SubtypeProfileNotFound,
},
{
name: "config default reports currentApp",
in: identityInputs{config: tenantA},
route: routeConfigDefault,
source: SourceConfigCurrentApp,
},
{
name: "config default without currentApp reports firstApp",
in: identityInputs{config: noCurrent},
route: routeConfigDefault,
source: SourceConfigFirstApp,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d, err := decideIdentity(tc.in)
if tc.subtype != "" {
if err == nil {
t.Fatalf("decideIdentity = %+v, want error subtype %q", d, tc.subtype)
}
prob, ok := errs.ProblemOf(err)
if !ok || prob.Subtype != tc.subtype {
t.Fatalf("error = %v, want subtype %q", err, tc.subtype)
}
return
}
if err != nil {
t.Fatalf("decideIdentity: %v", err)
}
if d.route != tc.route {
t.Errorf("route = %d, want %d", d.route, tc.route)
}
if d.selection.Source != tc.source {
t.Errorf("source = %q, want %q", d.selection.Source, tc.source)
}
if d.selection.DirectCredentialEnv.Matched != tc.matched {
t.Errorf("matched = %v, want %v", d.selection.DirectCredentialEnv.Matched, tc.matched)
}
})
}
}

View File

@@ -74,13 +74,9 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
// Load config once — used for both credentials and strict mode.
// LoadOrNotConfigured distinguishes an absent config (→ not_configured)
// from a malformed/unreadable one (→ invalid_config with cause), so a
// broken config is never masked as "run config init" — matching the
// explicit-profile path in doResolveAccount.
multi, err := core.LoadOrNotConfigured()
multi, err := core.LoadMultiAppConfig()
if err != nil {
return nil, err
return nil, core.NotConfiguredError()
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
@@ -120,7 +116,6 @@ type DefaultTokenProvider struct {
tatOnce sync.Once
tatResult *TokenResult
tatAppID string
tatErr error
}
@@ -131,42 +126,21 @@ func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient fun
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
switch req.Type {
case TokenTypeUAT:
return p.resolveUAT(ctx, req)
return p.resolveUAT(ctx)
case TokenTypeTAT:
return p.resolveTAT(ctx, req)
return p.resolveTAT(ctx)
default:
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
}
}
// checkTokenAppID refuses to hand out a token for a different app than the
// caller resolved. The token provider re-reads the config, so a concurrent
// profile edit between account resolution and token resolution could otherwise
// cross tokens between apps. TokenSpec.AppID is REQUIRED here: an empty value
// would silently disable the guarantee, so it is rejected rather than skipped.
func checkTokenAppID(req TokenSpec, resolvedAppID string) error {
if req.AppID == "" {
return errs.NewInternalError(errs.SubtypeUnknown,
"TokenSpec.AppID is required for %s token resolution", req.Type)
}
if req.AppID == resolvedAppID {
return nil
}
return errs.NewInternalError(errs.SubtypeUnknown,
"config changed during resolution: token requested for app %q but the saved profile now resolves to a different app", req.AppID).
WithHint("retry the command.")
}
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
// may be refreshed between calls and GetValidAccessToken handles its own caching.
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if err := checkTokenAppID(req, acct.AppID); err != nil {
return nil, err
}
httpClient, err := p.httpClient()
if err != nil {
return nil, err
@@ -183,36 +157,20 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*
return &TokenResult{Token: token, Scopes: scopes}, nil
}
// resolveTAT resolves a tenant access token. The result is cached after the
// first mint via sync.Once — only the context from that call is used.
//
// The account is resolved and checked against the request BEFORE any token
// work: a mismatched request must not trigger a token mint (network call,
// quota, audit trail) for the wrong app. The cached result is additionally
// re-checked on every hit, so a token minted for one app is never served to
// a request that resolved another.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
// resolveTAT resolves a tenant access token. The result is cached after the first
// call via sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
})
return p.tatResult, p.tatErr
}
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if err := checkTokenAppID(req, acct.AppID); err != nil {
return nil, err
}
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx, acct)
p.tatAppID = acct.AppID
})
if p.tatErr != nil {
return nil, p.tatErr
}
if err := checkTokenAppID(req, p.tatAppID); err != nil {
return nil, err
}
return p.tatResult, nil
}
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context, acct *Account) (*TokenResult, error) {
httpClient, err := p.httpClient()
if err != nil {
return nil, err

View File

@@ -4,15 +4,10 @@
package credential
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
@@ -97,136 +92,3 @@ func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
}
}
func TestCheckTokenAppID(t *testing.T) {
if err := checkTokenAppID(TokenSpec{Type: TokenTypeUAT}, "cli_a"); err == nil {
t.Fatal("empty requested app must be rejected: it would silently disable the guarantee")
}
if err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_a"); err != nil {
t.Fatalf("matching app must pass: %v", err)
}
err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_b")
if err == nil {
t.Fatal("mismatched app must be refused")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError", err)
}
}
// REAL-path regression for review F2: the token provider re-reads the config,
// so a profile edit between account resolution and token resolution must not
// hand a token minted for the new app to a caller that resolved the old one.
// Uses the real DefaultAccountProvider + DefaultTokenProvider; the HTTP stub
// makes the network step unreachable, so reaching it proves the app check ran
// and passed first.
func TestDefaultTokenProvider_RefusesTokenAfterConfigSwap(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeCfg := func(appID string) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: appID, AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
}
writeCfg("cli_a")
httpSentinel := errors.New("http client sentinel: unreachable in test")
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { return nil, httpSentinel },
nil,
)
// Matching app: the consistency check passes and resolution proceeds to
// the (stubbed) HTTP step.
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
if !errors.Is(err, httpSentinel) {
t.Fatalf("err = %v, want the HTTP sentinel (check must pass for a matching app)", err)
}
// The profile now resolves to a different app: the token request that was
// arbitrated for cli_a must be refused before any token work happens.
writeCfg("cli_b")
_, err = tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
t.Fatalf("err = %v, want config-changed refusal", err)
}
}
// F1 regression: a TAT request for a mismatched app must be refused BEFORE
// any token work starts — no HTTP client construction, no mint, no cache —
// otherwise the CLI mints (and caches) a token for the wrong app and only
// then refuses to return it, leaving auth audit/quota side effects behind.
func TestDefaultTokenProvider_TATChecksAppBeforeAnyTokenWork(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
httpCalled := false
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { httpCalled = true; return nil, errors.New("http sentinel") },
nil,
)
// The profile resolves to cli_b, but the caller arbitrated cli_a.
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"})
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
t.Fatalf("err = %v, want config-changed refusal", err)
}
if httpCalled {
t.Fatal("token work started for a mismatched app: the check must run before any HTTP client is built")
}
}
// countingTATTripper serves a canned successful TAT response and counts calls.
type countingTATTripper struct{ calls int }
func (c *countingTATTripper) RoundTrip(*http.Request) (*http.Response, error) {
c.calls++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"your-access-token"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
}
// TAT happy path: the first request mints the token over HTTP, the second is
// served from the sync.Once cache without another HTTP call.
func TestDefaultTokenProvider_TATSuccessAndCacheHit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig: %v", err)
}
tripper := &countingTATTripper{}
tp := NewDefaultTokenProvider(
NewDefaultAccountProvider(nil, "tenant_a"),
func() (*http.Client, error) { return &http.Client{Transport: tripper}, nil },
nil,
)
req := TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"}
first, err := tp.ResolveToken(context.Background(), req)
if err != nil || first.Token != "your-access-token" {
t.Fatalf("first resolve = %+v, %v; want minted token", first, err)
}
second, err := tp.ResolveToken(context.Background(), req)
if err != nil || second.Token != "your-access-token" {
t.Fatalf("second resolve = %+v, %v; want cached token", second, err)
}
if tripper.calls != 1 {
t.Fatalf("HTTP calls = %d, want exactly 1 (second resolve must hit the cache)", tripper.calls)
}
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
// CredentialSourceKind is the wire-stable App/credential selection source.
type CredentialSourceKind string
const (
SourceFlagProfile CredentialSourceKind = "flag:--profile"
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
// SourceExtensionPrefix prefixes the name of a managed extension provider
// that won selection outright (e.g. "extension:sidecar"). With it, an
// empty Source is left with exactly one meaning: not resolved.
SourceExtensionPrefix CredentialSourceKind = "extension:"
)
// SourceExtension reports the selection source for a managed extension
// provider by name.
func SourceExtension(name string) CredentialSourceKind {
return SourceExtensionPrefix + CredentialSourceKind(name)
}
// DirectCredentialEnv describes the state of direct app credential env vars.
// It never carries a secret value — only names and the non-sensitive app_id.
type DirectCredentialEnv struct {
Present bool `json:"present"`
Keys []string `json:"keys,omitempty"`
AppID string `json:"appId,omitempty"`
Matched bool `json:"matched,omitempty"`
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
}
// IdentitySelection is the explainable result of credential selection.
// It carries NO secret value.
type IdentitySelection struct {
Source CredentialSourceKind
DirectCredentialEnv DirectCredentialEnv
}
// Explicit reports whether the identity was actively specified by the
// user/agent (flag or env), which governs no-fallback behavior.
func (s IdentitySelection) Explicit() bool {
switch s.Source {
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
return true
default:
return false
}
}

View File

@@ -1,25 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import "testing"
func TestIdentitySelectionExplicit(t *testing.T) {
cases := []struct {
src CredentialSourceKind
explicit bool
}{
{SourceFlagProfile, true},
{SourceEnvProfile, true},
{SourceEnvAppID, true},
{SourceConfigCurrentApp, false},
{SourceConfigFirstApp, false},
}
for _, c := range cases {
sel := IdentitySelection{Source: c.src}
if sel.Explicit() != c.explicit {
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
}
}
}

View File

@@ -52,24 +52,6 @@ func TestFullChain_EnvWins(t *testing.T) {
}
}
func TestFullChain_EnvRejectsDifferentApp(t *testing.T) {
t.Setenv(envvars.CliAppID, "env_app")
t.Setenv(envvars.CliAppSecret, "env_secret")
t.Setenv(envvars.CliUserAccessToken, "env_uat")
cp := credential.NewCredentialProvider(
[]extcred.Provider{&envprovider.Provider{}},
nil, nil, nil,
)
_, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "other_app",
})
if err == nil {
t.Fatal("ResolveToken() error = nil, want app binding error")
}
}
func TestFullChain_Fallthrough(t *testing.T) {
// env provider returns nil (no env vars set), falls through to default token
ep := &envprovider.Provider{}
@@ -77,8 +59,7 @@ func TestFullChain_Fallthrough(t *testing.T) {
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
&mockDefaultAccountProvider{account: &credential.Account{AppID: "app1"}},
mock, nil,
nil, mock, nil,
)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "app1",
@@ -91,14 +72,6 @@ func TestFullChain_Fallthrough(t *testing.T) {
}
}
type mockDefaultAccountProvider struct {
account *credential.Account
}
func (m *mockDefaultAccountProvider) ResolveAccount(context.Context) (*credential.Account, error) {
return m.account, nil
}
type mockDefaultTokenProvider struct {
token string
scopes string

View File

@@ -21,7 +21,6 @@ const (
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProfile = "LARKSUITE_CLI_PROFILE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"

View File

@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
const agentName = "sample-agent"
t.Setenv(CliAgentName, agentName)
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q", got, agentName)
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
const agentName = "sample-agent"
t.Setenv(CliAgentName, " "+agentName+" ")
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
}
}

View File

@@ -38,6 +38,10 @@ type Stub struct {
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// Optional (optional): when true, Verify does not require this stub to be
// matched. Useful for negative assertions via OnMatch.
Optional bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
if s.matched {
continue
}
if s.Optional {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue

View File

@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
## Public Domain Allowlists
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
## Semantic Blocker Policy
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:

View File

@@ -0,0 +1,24 @@
# Exact test-only hostnames. Keep sorted.
abc.feishu.cn
attacker.example.com
bytedance.feishu.cn
cdn.feishu.cn
evil.example.com
example.feishu.cn
example.larkoffice.com
example.larksuite.com
feishu.cn
feishu.doubao.com
gateway.docker.internal
host.containers.internal
host.docker.internal
host.lima.internal
lf3-static.bytednsdoc.com
meetings.feishu.cn
meetings.larksuite.com
p3-lark-file.byteimg.com
passport.feishu.cn
sample.feishu.cn
x.feishu.cn
xxx.feishu.cn
xxx.larksuite.com

View File

@@ -0,0 +1,18 @@
# Exact public hostnames. Keep sorted.
accounts.feishu.cn
accounts.larksuite.com
applink.feishu.cn
applink.larksuite.com
ark.ap-southeast.bytepluses.com
github.com
larkoffice.com
lf-larkemail.bytetos.com
mcp.feishu.cn
mcp.larksuite.com
open.feishu.cn
open.larksuite.com
registry.npmjs.org
registry.npmmirror.com
sf16-sg.tiktokcdn.com
www.feishu.cn
www.larksuite.com

View File

@@ -19,7 +19,7 @@ lint/
├── lintapi/ # shared types every domain returns
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
└── errscontract/ # first domain: typed-error contract guards
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
├── runner.go
├── typecheck.go
├── violation.go # local type aliases to lintapi
@@ -30,16 +30,19 @@ lint/
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_test.go
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
── scan_test.go
└── domaincontract/ # resolver ownership + approved public hostname policy
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
── unapproved.go # Go AST/type-aware hostname extraction
├── policy.go # exact public/fixture allowlist validation
├── diff.go # added-line attribution
└── *_test.go
```
## Endpoint domain contract (`domaincontract`)
`domaincontract` is a syntax-level regression guard for the resolver-owned
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
files it rejects:
`domaincontract` contains two complementary Go source guards.
The resolver-ownership guard rejects:
- string literals containing a resolver-owned host FQDN
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
@@ -59,17 +62,54 @@ parse-level guard). The forbidden-host list is bound to the resolver source by
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
the guard fails the lint module's tests.
This is not a general outbound-URL or data-flow analyzer. It does not inspect
non-Go assets, hosts assembled from string fragments, SDK constructor option
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
remain the backstop for those cases.
The approved-domain guard parses every Git-tracked Go file in full. In CI,
unapproved-host findings are limited to values whose expressions intersect an
added line; policy validation and unused-entry checks remain repository-wide.
It rejects an exact hostname unless it is present in one of:
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
- `internal/qualitygate/config/allowlists/public-domains.txt`, for production
and test code; or
- `internal/qualitygate/config/allowlists/fixture-domains.txt`, only for
`*_test.go`, the repository-root `tests/`, and any `testdata/` (never
`skills/`).
RFC 2606 example/test names are accepted independently of those lists. This
includes the reserved `.test`, `.example`, `.invalid`, and `.localhost`
namespaces and the exact names `example.com`, `example.net`, and `example.org`;
they are safe placeholders rather than supported public endpoints.
High-confidence evidence is deliberately limited to static string expressions
assigned to `host`, `hostname`, or `domain` semantic names (including common
case/plural forms and collections), plus static strings whose entire value is
an absolute `http`, `https`, `ws`, or `wss` URL. It supports Go literals,
escapes, compile-time concatenation, constant references, grouped declarations,
multi-value assignments, and multiline expressions. Bare domain-shaped strings
without hostname semantics are not blocked.
Sequence values are scanned individually. For a hostname-semantic map, a key or
value is evidence only when it is the sole hostname-shaped side of that entry;
ambiguous string-to-string entries are not guessed. Struct fields use Go type
information so known non-network `Host` / `Domain` fields do not become hostname
evidence merely because an enum or command category contains a dot.
Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS,
or public-suffix exceptions. Entries must be sorted and unique, use ASCII
hostnames, and have a current in-scope use. See
`internal/qualitygate/config/README.md` for admission and approval rules.
This is not a general outbound-URL or cross-language data-flow analyzer. It does
not inspect non-Go assets or dynamically constructed values.
To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather
than hardcoding the host elsewhere.
## Running
```bash
# from the repo root (one level above lint/)
# PR-scoped scan from the repo root (one level above lint/)
go run -C lint . --changed-from <base-revision> ..
# Full inventory (also reports historical unapproved hostnames)
go run -C lint . ..
```
@@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`:
import "github.com/larksuite/cli/lint/lintapi"
// ScanRepo walks root and returns every violation produced by this
// domain's checks. Domains MUST return []lintapi.Violation so the
// top-level dispatcher can aggregate uniformly.
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
type ScanOptions struct {
ChangedFrom string
}
// ScanRepoWithOptions walks root and returns every violation produced
// by this domain's checks. Domains MUST return []lintapi.Violation so
// the top-level dispatcher can aggregate uniformly.
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... }
```
3. Per-rule files are named `rule_<name>.go` with sibling
@@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`:
```go
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepo},
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "<domain>", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return <domain>.ScanRepoWithOptions(root, <domain>.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
}},
}
```

171
lint/domaincontract/diff.go Normal file
View File

@@ -0,0 +1,171 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type addedLineRange struct {
Start int
End int
}
type changedGoPath struct {
Old string
New string
}
var unifiedHunkRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`)
func changedGoLineRanges(root, from string) (map[string][]addedLineRange, error) {
if from == "" {
return nil, nil
}
names, err := gitCommandOutput(
root,
"diff",
"--name-status",
"-z",
"--find-renames",
"--diff-filter=ACMR",
from+"...HEAD",
"--",
)
if err != nil {
return nil, fmt.Errorf("list changed Go files: %w", err)
}
paths, err := parseChangedGoPaths(names)
if err != nil {
return nil, fmt.Errorf("parse changed Go files: %w", err)
}
out := map[string][]addedLineRange{}
for _, path := range paths {
args := []string{
"diff",
"--unified=0",
"--no-color",
"--no-ext-diff",
"--find-renames",
"--diff-filter=ACMR",
from + "...HEAD",
"--",
}
if path.Old != path.New {
args = append(args, path.Old)
}
args = append(args, path.New)
patch, err := gitCommandOutput(root, args...)
if err != nil {
return nil, fmt.Errorf("read diff for %s: %w", path.New, err)
}
ranges, err := parseAddedLineRanges(patch)
if err != nil {
return nil, fmt.Errorf("parse diff for %s: %w", path.New, err)
}
out[path.New] = ranges
}
return out, nil
}
func parseChangedGoPaths(raw []byte) ([]changedGoPath, error) {
fields := bytes.Split(raw, []byte{0})
var out []changedGoPath
for i := 0; i < len(fields); {
status := string(fields[i])
i++
if status == "" {
break
}
if i >= len(fields) || len(fields[i]) == 0 {
return nil, fmt.Errorf("truncated name-status record")
}
oldPath := filepath.ToSlash(string(fields[i]))
i++
newPath := oldPath
if status[0] == 'R' || status[0] == 'C' {
if i >= len(fields) || len(fields[i]) == 0 {
return nil, fmt.Errorf("truncated rename/copy record for %q", oldPath)
}
newPath = filepath.ToSlash(string(fields[i]))
i++
if status[0] == 'C' {
// A copy introduces every destination line. Diff only the new
// path so Git presents it as an added file rather than a
// metadata-only copy with no added-line ranges.
oldPath = newPath
}
}
if !strings.HasSuffix(newPath, ".go") {
continue
}
out = append(out, changedGoPath{Old: oldPath, New: newPath})
}
return out, nil
}
func parseAddedLineRanges(patch []byte) ([]addedLineRange, error) {
var out []addedLineRange
for _, raw := range bytes.Split(patch, []byte{'\n'}) {
line := string(raw)
if !strings.HasPrefix(line, "@@") {
continue
}
match := unifiedHunkRE.FindStringSubmatch(line)
if match == nil {
return nil, fmt.Errorf("unsupported unified hunk header %q", line)
}
start, err := strconv.Atoi(match[1])
if err != nil {
return nil, fmt.Errorf("parse added start line in %q: %w", line, err)
}
count := 1
if match[2] != "" {
count, err = strconv.Atoi(match[2])
if err != nil {
return nil, fmt.Errorf("parse added line count in %q: %w", line, err)
}
}
if count == 0 {
continue
}
out = append(out, addedLineRange{Start: start, End: start + count - 1})
}
return out, nil
}
func firstAddedLineInSpan(ranges []addedLineRange, start, end int) (int, bool) {
for _, r := range ranges {
if start <= r.End && end >= r.Start {
if start > r.Start {
return start, true
}
return r.Start, true
}
}
return 0, false
}
func gitCommandOutput(root string, args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.Output()
if err == nil {
return out, nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
if stderr != "" {
return nil, fmt.Errorf("%w: %s", err, stderr)
}
}
return nil, err
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import "testing"
func TestParseChangedGoPaths(t *testing.T) {
raw := []byte("M\x00changed.go\x00R100\x00old.go\x00renamed.go\x00C100\x00source.go\x00copied.go\x00A\x00README.md\x00")
got, err := parseChangedGoPaths(raw)
if err != nil {
t.Fatal(err)
}
want := []changedGoPath{
{Old: "changed.go", New: "changed.go"},
{Old: "old.go", New: "renamed.go"},
{Old: "copied.go", New: "copied.go"},
}
if len(got) != len(want) {
t.Fatalf("paths = %#v, want %#v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("paths = %#v, want %#v", got, want)
}
}
}
func TestParseChangedGoPathsRejectsTruncatedRename(t *testing.T) {
if _, err := parseChangedGoPaths([]byte("R100\x00old.go\x00")); err == nil {
t.Fatal("expected truncated rename error")
}
}
func TestParseAddedLineRanges(t *testing.T) {
patch := []byte(`diff --git a/x.go b/x.go
index 1111111..2222222 100644
--- a/x.go
+++ b/x.go
@@ -2,0 +3,2 @@
+first
+second
@@ -10 +12 @@
-old
+new
@@ -20 +21,0 @@
-deleted
`)
got, err := parseAddedLineRanges(patch)
if err != nil {
t.Fatal(err)
}
want := []addedLineRange{{Start: 3, End: 4}, {Start: 12, End: 12}}
if len(got) != len(want) {
t.Fatalf("ranges = %#v, want %#v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("ranges = %#v, want %#v", got, want)
}
}
}
func TestParseAddedLineRangesRejectsUnknownHunk(t *testing.T) {
if _, err := parseAddedLineRanges([]byte("@@@ unsupported @@@\n")); err == nil {
t.Fatal("expected unsupported hunk error")
}
}
func TestFirstAddedLineInSpan(t *testing.T) {
ranges := []addedLineRange{{Start: 5, End: 7}, {Start: 10, End: 10}}
tests := []struct {
start, end int
line int
ok bool
}{
{start: 1, end: 4, ok: false},
{start: 4, end: 6, line: 5, ok: true},
{start: 6, end: 9, line: 6, ok: true},
{start: 8, end: 12, line: 10, ok: true},
}
for _, tc := range tests {
line, ok := firstAddedLineInSpan(ranges, tc.start, tc.end)
if line != tc.line || ok != tc.ok {
t.Errorf(
"firstAddedLineInSpan(%d, %d) = (%d, %v), want (%d, %v)",
tc.start,
tc.end,
line,
ok,
tc.line,
tc.ok,
)
}
}
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
publicDomainsPath = "internal/qualitygate/config/allowlists/public-domains.txt"
fixtureDomainsPath = "internal/qualitygate/config/allowlists/fixture-domains.txt"
)
type domainPolicyEntry struct {
Host string
File string
Line int
}
type domainPolicy struct {
Public map[string]domainPolicyEntry
Fixtures map[string]domainPolicyEntry
}
// isReservedExampleHostname recognizes only names reserved by RFC 2606 for
// examples, testing, invalid-name examples, and localhost use. These names are
// safe source placeholders and are policy exceptions, not supported public
// endpoints.
func isReservedExampleHostname(host string) bool {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
switch host {
case "example.com", "example.net", "example.org":
return true
}
labels := strings.Split(host, ".")
switch labels[len(labels)-1] {
case "test", "example", "invalid", "localhost":
return true
default:
return false
}
}
func loadDomainPolicy(root string) (domainPolicy, error) {
public, err := loadDomainList(root, publicDomainsPath)
if err != nil {
return domainPolicy{}, err
}
fixtures, err := loadDomainList(root, fixtureDomainsPath)
if err != nil {
return domainPolicy{}, err
}
for host, entry := range fixtures {
if publicEntry, ok := public[host]; ok {
return domainPolicy{}, fmt.Errorf(
"%s:%d: hostname %q is already listed at %s:%d",
entry.File, entry.Line, host, publicEntry.File, publicEntry.Line,
)
}
}
return domainPolicy{Public: public, Fixtures: fixtures}, nil
}
func loadDomainList(root, rel string) (map[string]domainPolicyEntry, error) {
path := filepath.Join(root, filepath.FromSlash(rel))
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open domain allowlist %s: %w", rel, err)
}
defer file.Close()
entries := map[string]domainPolicyEntry{}
var previous string
scanner := bufio.NewScanner(file)
for line := 1; scanner.Scan(); line++ {
host := strings.TrimSpace(scanner.Text())
if host == "" || strings.HasPrefix(host, "#") {
continue
}
if host != strings.ToLower(host) {
return nil, fmt.Errorf("%s:%d: hostname must be lowercase: %q", rel, line, host)
}
if err := validatePolicyHostname(host); err != nil {
return nil, fmt.Errorf("%s:%d: %w", rel, line, err)
}
if previous != "" && host <= previous {
return nil, fmt.Errorf("%s:%d: hostnames must be unique and sorted: %q", rel, line, host)
}
entries[host] = domainPolicyEntry{Host: host, File: rel, Line: line}
previous = host
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read domain allowlist %s: %w", rel, err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("%s: domain list must not be empty", rel)
}
return entries, nil
}
func validatePolicyHostname(host string) error {
if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") {
return fmt.Errorf("invalid exact hostname %q", host)
}
labels := strings.Split(host, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("invalid exact hostname %q", host)
}
for _, r := range label {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
continue
}
return fmt.Errorf("invalid exact hostname %q", host)
}
}
if !strings.ContainsAny(labels[len(labels)-1], "abcdefghijklmnopqrstuvwxyz") {
return fmt.Errorf("invalid exact hostname %q", host)
}
return nil
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"strings"
"testing"
)
func TestLoadDomainPolicy(t *testing.T) {
root := t.TempDir()
writeFile(t, root, publicDomainsPath, "# public\napi.example.com\nwww.example.com\n")
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
policy, err := loadDomainPolicy(root)
if err != nil {
t.Fatal(err)
}
if len(policy.Public) != 2 || len(policy.Fixtures) != 1 {
t.Fatalf("unexpected policy sizes: public=%d fixtures=%d", len(policy.Public), len(policy.Fixtures))
}
if policy.Public["api.example.com"].Line != 2 {
t.Fatalf("api.example.com line = %d, want 2", policy.Public["api.example.com"].Line)
}
}
func TestLoadDomainPolicyRejectsInvalidLists(t *testing.T) {
tests := []struct {
name string
public string
fixtures string
want string
}{
{
name: "uppercase",
public: "API.example.com\n",
fixtures: "fixture.example.com\n",
want: "must be lowercase",
},
{
name: "unsorted",
public: "www.example.com\napi.example.com\n",
fixtures: "fixture.example.com\n",
want: "unique and sorted",
},
{
name: "duplicate",
public: "api.example.com\napi.example.com\n",
fixtures: "fixture.example.com\n",
want: "unique and sorted",
},
{
name: "wildcard",
public: "*.example.com\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "scheme",
public: "https://example.com\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "path",
public: "api.example.com/v1\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "port",
public: "api.example.com:443\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "cross-list duplicate",
public: "api.example.com\n",
fixtures: "api.example.com\n",
want: "already listed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
writeFile(t, root, publicDomainsPath, tc.public)
writeFile(t, root, fixtureDomainsPath, tc.fixtures)
_, err := loadDomainPolicy(root)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("loadDomainPolicy() error = %v, want substring %q", err, tc.want)
}
})
}
}
func TestReservedExampleHostname(t *testing.T) {
for _, host := range []string{
"example.com",
"example.net",
"example.org",
"example.test",
"docs.example",
"missing.invalid",
"service.localhost",
} {
if !isReservedExampleHostname(host) {
t.Errorf("%q should be a reserved example hostname", host)
}
}
for _, host := range []string{
"attacker.example.com",
"example.dev",
"private.corp.internal",
} {
if isReservedExampleHostname(host) {
t.Errorf("%q must still require policy approval", host)
}
}
}

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package domaincontract guards the Go CLI against direct reuse of the current
// resolver-owned host FQDNs outside core.ResolveEndpoints.
// Package domaincontract guards resolver ownership and rejects newly introduced
// static Go hostnames that are not covered by the repository domain policy.
package domaincontract
import (
@@ -11,6 +11,7 @@ import (
"go/token"
"io/fs"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -75,10 +76,40 @@ func skipDir(name string) bool {
return false
}
// ScanRepo walks production .go files under root and flags string literals
// containing a forbidden resolver host outside the allowlist. Comments and
// _test.go files are not scanned.
// ScanRepo runs the resolver-owned endpoint guard and a full repository domain
// inventory. CI should use ScanRepoWithOptions with a changed-from revision so
// historical unapproved domains are not attributed to an unrelated change.
func ScanRepo(root string) ([]lintapi.Violation, error) {
return ScanRepoWithOptions(root, ScanOptions{})
}
type ScanOptions struct {
ChangedFrom string
}
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) {
out, err := scanHardcodedEndpoints(root)
if err != nil {
return nil, err
}
domainViolations, err := scanUnapprovedDomains(root, opts)
if err != nil {
return nil, err
}
out = append(out, domainViolations...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out, nil
}
func scanHardcodedEndpoints(root string) ([]lintapi.Violation, error) {
var out []lintapi.Violation
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {

View File

@@ -0,0 +1,911 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"fmt"
"go/ast"
"go/constant"
"go/parser"
"go/token"
"go/types"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/larksuite/cli/lint/lintapi"
"golang.org/x/tools/go/packages"
)
const (
unapprovedDomainRule = "unapproved-domain"
unusedDomainRule = "domain-allowlist-unused"
incompleteDomainRule = "domain-scan-incomplete"
)
type typedGoFile struct {
File *ast.File
Fset *token.FileSet
Info *types.Info
}
type domainEvidence struct {
Host string
Kind string
Expr ast.Expr
}
type evidenceKey struct {
Host string
Start, End token.Pos
}
type fileDomainScan struct {
File *ast.File
Fset *token.FileSet
Info *types.Info
Evidence []domainEvidence
TypeInfoRequired []ast.Expr
seen map[evidenceKey]bool
parents map[ast.Node]ast.Node
}
type collectionCompositeKind uint8
const (
notCollectionComposite collectionCompositeKind = iota
sequenceComposite
mapComposite
)
type hostnameFieldID struct {
Type string
Field string
}
var nonNetworkHostnameFields = map[hostnameFieldID]bool{
{Type: "github.com/larksuite/cli/events/im.CardActionTriggerOutput", Field: "Host"}: true,
{Type: "github.com/larksuite/cli/internal/cmdmeta.Meta", Field: "Domain"}: true,
}
func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve repository root: %w", err)
}
publicPath := filepath.Join(root, filepath.FromSlash(publicDomainsPath))
if _, err := os.Stat(publicPath); err != nil {
if os.IsNotExist(err) {
if _, goModErr := os.Stat(filepath.Join(root, "go.mod")); os.IsNotExist(goModErr) {
return nil, nil
}
}
return nil, fmt.Errorf("domain policy unavailable: %w", err)
}
policy, err := loadDomainPolicy(root)
if err != nil {
return nil, err
}
added, err := changedGoLineRanges(root, opts.ChangedFrom)
if err != nil {
return nil, err
}
typed, typeLoadErr := loadTypedGoFiles(root)
goFiles, err := trackedGoFiles(root)
if err != nil {
return nil, err
}
observedPublic := map[string]bool{}
observedFixtures := map[string]bool{}
inventoryComplete := typeLoadErr == nil
var out []lintapi.Violation
parseFailureReported := false
typeInfoGapReported := false
for _, rel := range goFiles {
path := filepath.Join(root, filepath.FromSlash(rel))
parsedFset := token.NewFileSet()
parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0)
if parseErr != nil {
inventoryComplete = false
if opts.ChangedFrom == "" {
out = append(out, incompleteDomainViolation(rel, parseErr))
parseFailureReported = true
} else if _, changed := added[rel]; changed {
out = append(out, incompleteDomainViolation(rel, parseErr))
parseFailureReported = true
}
continue
}
tf, ok := typed[filepath.Clean(path)]
if !ok {
tf = typedGoFile{File: parsedFile, Fset: parsedFset}
}
scan := newFileDomainScan(tf)
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
if len(scan.TypeInfoRequired) > 0 {
// Inventory completeness is a property of the whole HEAD. Whether
// this PR owns an incomplete-scan diagnostic is decided separately
// by the added-line intersection below.
inventoryComplete = false
}
for _, expr := range scan.TypeInfoRequired {
start := tf.Fset.Position(expr.Pos()).Line
end := tf.Fset.Position(expr.End()).Line
line := start
if opts.ChangedFrom != "" {
var intersects bool
line, intersects = firstAddedLineInSpan(added[rel], start, end)
if !intersects {
continue
}
}
typeInfoGapReported = true
out = append(out, incompleteDomainViolationAt(
rel,
line,
fmt.Errorf("Go type information unavailable for hostname-oriented field evidence"),
))
break
}
fixture := isDomainFixturePath(rel)
// The detector's own policy literals and contract corpus may be
// scanned, but they cannot justify keeping an allowlist entry.
policyOwner := strings.HasPrefix(rel, "lint/domaincontract/")
for _, evidence := range scan.Evidence {
if isReservedExampleHostname(evidence.Host) {
continue
}
if _, ok := policy.Public[evidence.Host]; ok {
if !fixture && !policyOwner {
observedPublic[evidence.Host] = true
}
continue
}
if _, ok := policy.Fixtures[evidence.Host]; ok && fixture {
if !policyOwner {
observedFixtures[evidence.Host] = true
}
continue
}
start := tf.Fset.Position(evidence.Expr.Pos()).Line
end := tf.Fset.Position(evidence.Expr.End()).Line
line := start
if opts.ChangedFrom != "" {
var intersects bool
line, intersects = firstAddedLineInSpan(added[rel], start, end)
if !intersects {
continue
}
}
suggestion := "remove the hostname or replace it with an approved public endpoint; " +
"public allowlist additions require evidence and CODEOWNER approval"
if _, fixtureOnly := policy.Fixtures[evidence.Host]; fixtureOnly && !fixture {
suggestion = "remove the fixture-only hostname or move this use into an approved fixture scope; " +
"fixture entries are not approved for production Go code or skills"
}
out = append(out, lintapi.Violation{
Rule: unapprovedDomainRule,
Action: lintapi.ActionReject,
File: rel,
Line: line,
Message: fmt.Sprintf(
"unapproved hostname %q found in %s",
evidence.Host,
evidence.Kind,
),
Suggestion: suggestion,
})
}
}
// A syntax error is also surfaced by go/packages. Prefer the file-specific
// parse diagnostic when one was already reported; otherwise make a
// repository-wide type-loading failure explicit instead of silently
// continuing without the type information required by field evidence.
if typeLoadErr != nil && !parseFailureReported && !typeInfoGapReported {
out = append(out, incompleteDomainViolation("go.mod", typeLoadErr))
}
if inventoryComplete {
for host, entry := range policy.Public {
if !observedPublic[host] {
out = append(out, unusedDomainViolation(entry))
}
}
for host, entry := range policy.Fixtures {
if !observedFixtures[host] {
out = append(out, unusedDomainViolation(entry))
}
}
}
return out, nil
}
func trackedGoFiles(root string) ([]string, error) {
out, err := gitCommandOutput(root, "ls-files", "-z", "--", "*.go")
if err != nil {
return nil, fmt.Errorf("list tracked Go files: %w", err)
}
var files []string
for _, raw := range strings.Split(string(out), "\x00") {
if raw == "" {
continue
}
rel := filepath.ToSlash(raw)
if strings.HasPrefix(rel, "vendor/") || strings.HasPrefix(rel, "node_modules/") {
continue
}
files = append(files, rel)
}
return files, nil
}
func loadTypedGoFiles(root string) (map[string]typedGoFile, error) {
moduleDirs, err := trackedGoModuleDirs(root)
if err != nil {
return nil, err
}
out := map[string]typedGoFile{}
var firstLoadErr error
var loadErrCount int
for _, moduleDir := range moduleDirs {
moduleRoot := root
if moduleDir != "." {
moduleRoot = filepath.Join(root, filepath.FromSlash(moduleDir))
}
files, err := loadTypedGoModule(moduleRoot)
for path, file := range files {
out[path] = file
}
if err != nil {
loadErrCount++
if firstLoadErr == nil {
firstLoadErr = err
}
}
}
if loadErrCount == 1 {
return out, firstLoadErr
}
if loadErrCount > 1 {
return out, fmt.Errorf("%w (and %d more module errors)", firstLoadErr, loadErrCount-1)
}
return out, nil
}
func trackedGoModuleDirs(root string) ([]string, error) {
raw, err := gitCommandOutput(root, "ls-files", "-z")
if err != nil {
return nil, fmt.Errorf("list tracked Go modules: %w", err)
}
var dirs []string
for _, path := range strings.Split(string(raw), "\x00") {
path = filepath.ToSlash(path)
if path != "go.mod" && !strings.HasSuffix(path, "/go.mod") {
continue
}
dir := filepath.ToSlash(filepath.Dir(path))
dirs = append(dirs, dir)
}
return dirs, nil
}
func loadTypedGoModule(moduleRoot string) (map[string]typedGoFile, error) {
fset := token.NewFileSet()
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedImports |
packages.NeedDeps |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo,
Dir: moduleRoot,
Fset: fset,
Tests: true,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
return nil, fmt.Errorf("load Go type information: %w", err)
}
out := map[string]typedGoFile{}
var firstPackageErr string
var packageErrCount int
packages.Visit(pkgs, nil, func(pkg *packages.Package) {
if pkg == nil {
return
}
for _, pkgErr := range pkg.Errors {
packageErrCount++
if firstPackageErr == "" {
firstPackageErr = pkgErr.Error()
}
}
if pkg.TypesInfo == nil || pkg.Fset == nil {
return
}
for i, file := range pkg.Syntax {
if i >= len(pkg.CompiledGoFiles) {
break
}
path := filepath.Clean(pkg.CompiledGoFiles[i])
if _, exists := out[path]; exists {
continue
}
out[path] = typedGoFile{File: file, Fset: pkg.Fset, Info: pkg.TypesInfo}
}
})
if packageErrCount == 1 {
return out, fmt.Errorf("load Go type information: %s", firstPackageErr)
}
if packageErrCount > 1 {
return out, fmt.Errorf(
"load Go type information: %s (and %d more package errors)",
firstPackageErr,
packageErrCount-1,
)
}
return out, nil
}
func newFileDomainScan(file typedGoFile) *fileDomainScan {
return &fileDomainScan{
File: file.File,
Fset: file.Fset,
Info: file.Info,
seen: map[evidenceKey]bool{},
parents: astParentMap(file.File),
}
}
func (s *fileDomainScan) collectSemanticEvidence() {
ast.Inspect(s.File, func(node ast.Node) bool {
switch n := node.(type) {
case *ast.AssignStmt:
if len(n.Lhs) != len(n.Rhs) {
return true
}
for i, lhs := range n.Lhs {
if s.Info == nil &&
potentialHostnameSelectorTarget(lhs) &&
s.hasStaticBareHostnameValue(n.Rhs[i]) {
s.requireTypeInfo(n.Rhs[i])
}
if index, ok := stripParens(lhs).(*ast.IndexExpr); ok {
switch {
case s.isHostnameTarget(index.X):
s.addMapPair(index.Index, n.Rhs[i])
case s.isHostnameMapKey(index.Index):
s.addHostValue(n.Rhs[i], "host assignment")
}
continue
}
if s.isHostnameTarget(lhs) {
s.addHostValue(n.Rhs[i], "host assignment")
}
}
case *ast.ValueSpec:
if len(n.Names) != len(n.Values) {
return true
}
for i, name := range n.Names {
if isHostnameSemanticName(name.Name) {
s.addHostValue(n.Values[i], "host assignment")
}
}
case *ast.KeyValueExpr:
if s.Info == nil && s.keyValueNeedsTypeInfo(n) {
s.requireTypeInfo(n.Value)
}
if s.isHostnameKeyValue(n) {
s.addHostValue(n.Value, "host assignment")
}
}
return true
})
}
func (s *fileDomainScan) requireTypeInfo(expr ast.Expr) {
for _, existing := range s.TypeInfoRequired {
if existing.Pos() == expr.Pos() && existing.End() == expr.End() {
return
}
}
s.TypeInfoRequired = append(s.TypeInfoRequired, expr)
}
func (s *fileDomainScan) hasStaticBareHostnameValue(expr ast.Expr) bool {
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return false
}
host, ok := semanticHostname(value)
return ok && !isReservedExampleHostname(host)
}
func (s *fileDomainScan) keyValueNeedsTypeInfo(pair *ast.KeyValueExpr) bool {
composite, ok := s.parents[pair].(*ast.CompositeLit)
if !ok {
return false
}
if _, explicitMap := composite.Type.(*ast.MapType); explicitMap {
return false
}
key, ok := pair.Key.(*ast.Ident)
return ok && isHostnameSemanticName(key.Name) && s.hasStaticBareHostnameValue(pair.Value)
}
func potentialHostnameSelectorTarget(expr ast.Expr) bool {
switch n := stripParens(expr).(type) {
case *ast.SelectorExpr:
return isHostnameSemanticName(n.Sel.Name)
case *ast.StarExpr:
return potentialHostnameSelectorTarget(n.X)
case *ast.IndexExpr:
return potentialHostnameSelectorTarget(n.X)
default:
return false
}
}
func (s *fileDomainScan) collectAbsoluteURLEvidence() {
ast.Inspect(s.File, func(node ast.Node) bool {
expr, ok := node.(ast.Expr)
if !ok {
return true
}
if ident, ok := expr.(*ast.Ident); ok && s.Info != nil && s.Info.Defs[ident] != nil {
// A declaration name may carry the constant value in types.Info,
// but it is not a second source expression.
return true
}
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return true
}
if s.hasStaticStringContainer(expr) {
return true
}
host, ok := absoluteURLHostname(value)
if ok {
s.addEvidence(host, "absolute URL", expr)
}
return true
})
}
func (s *fileDomainScan) hasStaticStringContainer(expr ast.Expr) bool {
parent, ok := s.parents[expr].(ast.Expr)
if !ok {
return false
}
switch parent.(type) {
case *ast.BinaryExpr, *ast.ParenExpr:
_, ok := staticStringValue(parent, s.Info, nil)
return ok
default:
return false
}
}
func (s *fileDomainScan) addHostValue(expr ast.Expr, kind string) {
expr = stripParens(expr)
if composite, ok := expr.(*ast.CompositeLit); ok {
switch s.collectionCompositeKind(composite) {
case sequenceComposite:
for _, element := range composite.Elts {
if valueExpr, ok := element.(ast.Expr); ok {
s.addHostValue(valueExpr, "host collection")
}
}
case mapComposite:
for _, element := range composite.Elts {
pair, ok := element.(*ast.KeyValueExpr)
if !ok {
continue
}
keyExpr, ok := pair.Key.(ast.Expr)
if !ok {
continue
}
s.addMapPair(keyExpr, pair.Value)
}
default:
if s.Info == nil {
s.requireTypeInfoForUnclassifiedCollection(composite)
}
return
}
return
}
if evidence, ok := s.hostnameEvidence(expr, kind); ok {
s.addEvidence(evidence.Host, evidence.Kind, evidence.Expr)
}
}
func (s *fileDomainScan) requireTypeInfoForUnclassifiedCollection(composite *ast.CompositeLit) {
for _, element := range composite.Elts {
if pair, ok := element.(*ast.KeyValueExpr); ok {
keyExpr, ok := pair.Key.(ast.Expr)
if !ok {
continue
}
keyIsHost := s.hasStaticBareHostnameValue(keyExpr)
valueIsHost := s.hasStaticBareHostnameValue(pair.Value)
if keyIsHost == valueIsHost {
continue
}
if keyIsHost {
s.requireTypeInfo(keyExpr)
} else {
s.requireTypeInfo(pair.Value)
}
continue
}
valueExpr, ok := element.(ast.Expr)
if ok && s.hasStaticBareHostnameValue(valueExpr) {
s.requireTypeInfo(valueExpr)
}
}
}
// addMapPair reports a map side only when it is the sole hostname-shaped
// static value. A semantic map name does not establish whether a string map
// is hostname->metadata or alias->hostname, so reporting both sides would turn
// filenames such as client.pem into blocking hostname evidence.
func (s *fileDomainScan) addMapPair(key, value ast.Expr) {
keyEvidence, keyOK := s.hostnameEvidence(key, "host collection")
valueEvidence, valueOK := s.hostnameEvidence(value, "host collection")
if keyOK == valueOK {
return
}
if keyOK {
s.addEvidence(keyEvidence.Host, keyEvidence.Kind, keyEvidence.Expr)
return
}
s.addEvidence(valueEvidence.Host, valueEvidence.Kind, valueEvidence.Expr)
}
func (s *fileDomainScan) hostnameEvidence(expr ast.Expr, kind string) (domainEvidence, bool) {
expr = stripParens(expr)
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return domainEvidence{}, false
}
if host, ok := absoluteURLHostname(value); ok {
return domainEvidence{Host: host, Kind: "absolute URL", Expr: expr}, true
}
if host, ok := semanticHostname(value); ok {
return domainEvidence{Host: host, Kind: kind, Expr: expr}, true
}
return domainEvidence{}, false
}
func (s *fileDomainScan) collectionCompositeKind(expr *ast.CompositeLit) collectionCompositeKind {
if s.Info != nil {
if tv, ok := s.Info.Types[expr]; ok && tv.Type != nil {
switch tv.Type.Underlying().(type) {
case *types.Array, *types.Slice:
return sequenceComposite
case *types.Map:
return mapComposite
}
}
}
switch expr.Type.(type) {
case *ast.ArrayType:
return sequenceComposite
case *ast.MapType:
return mapComposite
default:
return notCollectionComposite
}
}
func (s *fileDomainScan) addEvidence(host, kind string, expr ast.Expr) {
key := evidenceKey{Host: host, Start: expr.Pos(), End: expr.End()}
if s.seen[key] {
return
}
s.seen[key] = true
s.Evidence = append(s.Evidence, domainEvidence{Host: host, Kind: kind, Expr: expr})
}
func staticStringValue(expr ast.Expr, info *types.Info, seen map[*ast.Object]bool) (string, bool) {
if info != nil {
if tv, ok := info.Types[expr]; ok && tv.Value != nil && tv.Value.Kind() == constant.String {
return constant.StringVal(tv.Value), true
}
}
switch n := expr.(type) {
case *ast.BasicLit:
if n.Kind != token.STRING {
return "", false
}
value, err := strconv.Unquote(n.Value)
return value, err == nil
case *ast.ParenExpr:
return staticStringValue(n.X, info, seen)
case *ast.BinaryExpr:
if n.Op != token.ADD {
return "", false
}
left, ok := staticStringValue(n.X, info, seen)
if !ok {
return "", false
}
right, ok := staticStringValue(n.Y, info, seen)
if !ok {
return "", false
}
return left + right, true
case *ast.Ident:
if info != nil {
if obj := info.ObjectOf(n); obj != nil {
if c, ok := obj.(*types.Const); ok {
if c.Val().Kind() == constant.String {
return constant.StringVal(c.Val()), true
}
}
}
}
if n.Obj == nil || n.Obj.Kind != ast.Con {
return "", false
}
if seen == nil {
seen = map[*ast.Object]bool{}
}
if seen[n.Obj] {
return "", false
}
seen[n.Obj] = true
defer delete(seen, n.Obj)
spec, ok := n.Obj.Decl.(*ast.ValueSpec)
if !ok {
return "", false
}
for i, name := range spec.Names {
if name.Name == n.Name && i < len(spec.Values) {
return staticStringValue(spec.Values[i], info, seen)
}
}
}
return "", false
}
func absoluteURLHostname(value string) (string, bool) {
value = strings.TrimSpace(value)
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" {
return "", false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https", "ws", "wss":
default:
return "", false
}
return normalizeCandidateHostname(parsed.Hostname())
}
func semanticHostname(value string) (string, bool) {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, `/\?#@`) || strings.ContainsAny(value, " \t\r\n") {
return "", false
}
parsed, err := url.Parse("//" + value)
if err != nil || parsed.Host == "" || parsed.Path != "" {
return "", false
}
return normalizeCandidateHostname(parsed.Hostname())
}
func normalizeCandidateHostname(host string) (string, bool) {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
if host == "" || !strings.Contains(host, ".") || net.ParseIP(host) != nil {
return "", false
}
labels := strings.Split(host, ".")
for _, label := range labels {
if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return "", false
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return "", false
}
}
return host, true
}
func (s *fileDomainScan) isHostnameTarget(expr ast.Expr) bool {
switch n := stripParens(expr).(type) {
case *ast.Ident:
return isHostnameSemanticName(n.Name)
case *ast.SelectorExpr:
return s.isHostnameSelector(n)
case *ast.StarExpr:
return s.isHostnameTarget(n.X)
default:
return false
}
}
func (s *fileDomainScan) isHostnameKeyValue(pair *ast.KeyValueExpr) bool {
composite, ok := s.parents[pair].(*ast.CompositeLit)
if !ok {
return false
}
switch s.collectionCompositeKind(composite) {
case mapComposite:
key, ok := pair.Key.(ast.Expr)
return ok && s.isHostnameMapKey(key)
case notCollectionComposite:
ident, ok := pair.Key.(*ast.Ident)
return ok && s.isHostnameStructField(composite, ident.Name)
default:
return false
}
}
func (s *fileDomainScan) isHostnameMapKey(expr ast.Expr) bool {
value, ok := staticStringValue(expr, s.Info, nil)
return ok && isHostnameSemanticName(value)
}
func (s *fileDomainScan) isHostnameSelector(selector *ast.SelectorExpr) bool {
if s.Info == nil || !isHostnameSemanticName(selector.Sel.Name) {
return false
}
selection := s.Info.Selections[selector]
if selection == nil || selection.Kind() != types.FieldVal {
return false
}
return !nonNetworkHostnameFields[hostnameFieldID{
Type: namedTypeID(selection.Recv()),
Field: selector.Sel.Name,
}]
}
func (s *fileDomainScan) isHostnameStructField(composite *ast.CompositeLit, field string) bool {
if s.Info == nil || !isHostnameSemanticName(field) {
return false
}
typeID := namedTypeID(s.Info.TypeOf(composite))
if typeID == "" {
return false
}
return !nonNetworkHostnameFields[hostnameFieldID{Type: typeID, Field: field}]
}
func namedTypeID(typ types.Type) string {
for {
switch t := typ.(type) {
case *types.Pointer:
typ = t.Elem()
case *types.Named:
obj := t.Obj()
if obj == nil || obj.Pkg() == nil {
return ""
}
return obj.Pkg().Path() + "." + obj.Name()
default:
return ""
}
}
}
func isHostnameSemanticName(name string) bool {
lower := strings.ToLower(name)
switch lower {
case "host", "hosts", "hostname", "hostnames", "domain", "domains":
return true
}
for _, marker := range []string{
"HostBy", "HostsBy", "HostnameBy", "HostnamesBy", "DomainBy", "DomainsBy",
} {
if i := strings.Index(name, marker); i >= 0 {
end := i + len(marker)
if end < len(name) && unicode.IsUpper(rune(name[end])) {
return true
}
}
}
for _, prefix := range []string{
"hostBy", "hostsBy", "hostnameBy", "hostnamesBy", "domainBy", "domainsBy",
} {
if strings.HasPrefix(name, prefix) &&
len(name) > len(prefix) &&
unicode.IsUpper(rune(name[len(prefix)])) {
return true
}
}
if i := strings.LastIndexAny(name, "_-"); i >= 0 {
return isHostnameSemanticName(name[i+1:])
}
for _, suffix := range []string{"Hostnames", "Hostname", "Domains", "Domain", "Hosts", "Host"} {
if strings.HasSuffix(name, suffix) && len(name) > len(suffix) {
return true
}
}
return false
}
func stripParens(expr ast.Expr) ast.Expr {
for {
paren, ok := expr.(*ast.ParenExpr)
if !ok {
return expr
}
expr = paren.X
}
}
func astParentMap(root ast.Node) map[ast.Node]ast.Node {
parents := map[ast.Node]ast.Node{}
var stack []ast.Node
ast.Inspect(root, func(node ast.Node) bool {
if node == nil {
stack = stack[:len(stack)-1]
return false
}
if len(stack) > 0 {
parents[node] = stack[len(stack)-1]
}
stack = append(stack, node)
return true
})
return parents
}
func isDomainFixturePath(rel string) bool {
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "skills/") {
return false
}
if strings.HasSuffix(rel, "_test.go") || strings.HasPrefix(rel, "tests/") {
return true
}
for _, part := range strings.Split(rel, "/") {
if part == "testdata" {
return true
}
}
return false
}
func unusedDomainViolation(entry domainPolicyEntry) lintapi.Violation {
return lintapi.Violation{
Rule: unusedDomainRule,
Action: lintapi.ActionReject,
File: entry.File,
Line: entry.Line,
Message: fmt.Sprintf("domain allowlist entry %q has no in-scope Go reference", entry.Host),
Suggestion: "remove the unused entry; allowlist entries must be justified by a current in-scope reference",
}
}
func incompleteDomainViolation(file string, err error) lintapi.Violation {
return incompleteDomainViolationAt(file, 1, err)
}
func incompleteDomainViolationAt(file string, line int, err error) lintapi.Violation {
return lintapi.Violation{
Rule: incompleteDomainRule,
Action: lintapi.ActionReject,
File: file,
Line: line,
Message: "domain scan incomplete: " + err.Error(),
Suggestion: "fix the Go parse or type-loading error so hostname analysis can complete",
}
}

View File

@@ -0,0 +1,462 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/lint/lintapi"
)
func gitTestCommand(t *testing.T, root string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
func setupDomainDiffRepo(t *testing.T, target string) (root, base string) {
t.Helper()
root = t.TempDir()
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n")
writeFile(t, root, publicDomainsPath, "# public\npublic.example.com\n")
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
writeFile(t, root, "policy_refs.go", "package sample\n\nvar APIHost = \"public.example.com\"\n")
writeFile(t, root, "policy_refs_test.go", "package sample\n\nvar FixtureHost = \"fixture.example.com\"\n")
writeFile(t, root, "target.go", target)
gitTestCommand(t, root, "init", "-q")
gitTestCommand(t, root, "config", "user.name", "Domain Contract Test")
gitTestCommand(t, root, "config", "user.email", "domain-contract@example.com")
gitTestCommand(t, root, "add", ".")
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", "base")
return root, gitTestCommand(t, root, "rev-parse", "HEAD")
}
func commitDomainDiff(t *testing.T, root, message string) {
t.Helper()
gitTestCommand(t, root, "add", "-A")
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", message)
}
func violationsForRule(vs []lintapi.Violation, rule string) []lintapi.Violation {
var out []lintapi.Violation
for _, v := range vs {
if v.Rule == rule {
out = append(out, v)
}
}
return out
}
func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation {
t.Helper()
vs, err := ScanRepoWithOptions(root, ScanOptions{ChangedFrom: base})
if err != nil {
t.Fatal(err)
}
return vs
}
func TestUnapprovedDomainDiffContract(t *testing.T) {
t.Run("new PR 1975 case", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar APIHost = \"internal-api-drive-stream.larkoffice.com\"\n")
commitDomainDiff(t, root, "add internal host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "internal-api-drive-stream.larkoffice.com") {
t.Fatalf("violations = %+v, want PR 1975 hostname", got)
}
})
t.Run("hostname field in nested Go module", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.23.0\n")
writeFile(t, root, "nested/target.go",
"package nested\n\ntype Config struct{ Host string }\n\n"+
"var config = Config{Host: \"private.corp.internal\"}\n")
commitDomainDiff(t, root, "add nested module hostname")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, unapprovedDomainRule)
if len(got) != 1 || filepath.ToSlash(got[0].File) != "nested/target.go" ||
!strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want nested-module hostname rejection", got)
}
if incomplete := violationsForRule(all, incompleteDomainRule); len(incomplete) != 0 {
t.Fatalf("nested module must have complete type information: %+v", incomplete)
}
})
t.Run("changed excluded field reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"var config = Config{Host: \"private.corp.internal\"}\n")
commitDomainDiff(t, root, "add excluded hostname field")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
t.Fatalf("violations = %+v, want changed field scan-incomplete at line 7", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped field must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded selector reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"func configure(config *Config) { config.Host = \"private.corp.internal\" }\n")
commitDomainDiff(t, root, "add excluded hostname selector")
got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
t.Fatalf("violations = %+v, want changed selector scan-incomplete at line 7", got)
}
})
t.Run("changed excluded named slice reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type HostList []string\n\n"+
"var AllowedHosts = HostList{\n\t\"attacker.zip\",\n}\n")
commitDomainDiff(t, root, "add excluded hostname slice")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
t.Fatalf("violations = %+v, want named-slice scan-incomplete at line 8", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped named slice must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded named map reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type HostSet map[string]struct{}\n\n"+
"var AllowedHosts = HostSet{\n\t\"attacker.zip\": {},\n}\n")
commitDomainDiff(t, root, "add excluded hostname map")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
t.Fatalf("violations = %+v, want named-map scan-incomplete at line 8", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped named map must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded unrelated code stays allowed", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\nvar unrelated = 2\n")
commitDomainDiff(t, root, "add excluded unrelated code")
if got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule); len(got) != 0 {
t.Fatalf("unrelated excluded code must not require hostname type information: %+v", got)
}
})
t.Run("new element in existing collection", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n}\n")
writeFile(t, root, "target.go",
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n\t\"attacker.zip\",\n}\n")
commitDomainDiff(t, root, "add collection host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
t.Fatalf("violations = %+v, want attacker.zip", got)
}
if got[0].Line != 5 {
t.Fatalf("violation line = %d, want 5", got[0].Line)
}
})
t.Run("multiline expression changed segment", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"example.com\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"internal\"\n")
commitDomainDiff(t, root, "change concatenated host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want private.corp.internal", got)
}
if got[0].Line != 4 {
t.Fatalf("violation line = %d, want changed line 4", got[0].Line)
}
})
t.Run("unrelated change beside historical hostname", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\nvar unrelated = 1\n")
commitDomainDiff(t, root, "add unrelated value")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected historical-domain violation: %+v", got)
}
})
t.Run("historical hostname expression changed", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar HistoricalHost = \"replacement.private.internal\"\n")
commitDomainDiff(t, root, "change historical host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "replacement.private.internal") {
t.Fatalf("violations = %+v, want replacement.private.internal", got)
}
})
t.Run("new assignment references existing constant", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nconst existingConst = \"private.corp.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nconst existingConst = \"private.corp.internal\"\nvar APIHost = existingConst\n")
commitDomainDiff(t, root, "use existing hostname constant")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want private.corp.internal", got)
}
if got[0].Line != 4 {
t.Fatalf("violation line = %d, want 4", got[0].Line)
}
})
t.Run("allowlisted hostname", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"public.example.com\"\n")
commitDomainDiff(t, root, "add public host")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected public-domain violation: %+v", got)
}
})
t.Run("reserved example URL", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nfunc fakeValue() string { return \"https://example.test/resource\" }\n")
commitDomainDiff(t, root, "add safe example URL")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected reserved-example violation: %+v", got)
}
})
t.Run("historical type gap suppresses unused policy diagnostics", func(t *testing.T) {
root, _ := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\nplatform.example.com\npublic.example.com\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"var config = Config{Host: \"platform.example.com\"}\n")
commitDomainDiff(t, root, "add historical platform hostname")
base := gitTestCommand(t, root, "rev-parse", "HEAD")
writeFile(t, root, "target.go", "package sample\n\nvar unrelated = 2\n")
commitDomainDiff(t, root, "change unrelated code")
all := scanDomainDiff(t, root, base)
if got := violationsForRule(all, incompleteDomainRule); len(got) != 0 {
t.Fatalf("historical type gap must not be attributed to this change: %+v", got)
}
if got := violationsForRule(all, unusedDomainRule); len(got) != 0 {
t.Fatalf("incomplete inventory must not produce unused-policy diagnostics: %+v", got)
}
})
t.Run("allowlist does not approve subdomains", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"evil.public.example.com\"\n")
commitDomainDiff(t, root, "add unapproved public subdomain")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.public.example.com") {
t.Fatalf("violations = %+v, want evil.public.example.com", got)
}
})
t.Run("multi assignment pairs names and values", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\nopen.larksuite.com\npublic.example.com\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar APIHost, BackupHost = \"open.larksuite.com\", \"attacker.zip\"\n")
commitDomainDiff(t, root, "add multiple hosts")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
t.Fatalf("violations = %+v, want only attacker.zip", got)
}
})
t.Run("IDN hostname is rejected", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"例子.公司.cn\"\n")
commitDomainDiff(t, root, "add IDN hostname")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "例子.公司.cn") {
t.Fatalf("violations = %+v, want IDN hostname", got)
}
})
t.Run("fixture limited to test files", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar ProductionHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in production")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
t.Fatalf("violations = %+v, want production fixture rejection", got)
}
if !strings.Contains(got[0].Suggestion, "fixture-only hostname") ||
strings.Contains(got[0].Suggestion, "public allowlist") {
t.Fatalf("suggestion = %q, want fixture-scope guidance", got[0].Suggestion)
}
})
t.Run("fixture accepted in test file", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "new_target_test.go",
"package sample\n\nvar BackupHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in test")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected fixture-domain violation: %+v", got)
}
})
t.Run("fixture allowlist does not approve subdomains", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "new_target_test.go",
"package sample\n\nvar BackupHost = \"evil.fixture.example.com\"\n")
commitDomainDiff(t, root, "use unapproved fixture subdomain")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.fixture.example.com") {
t.Fatalf("violations = %+v, want exact fixture match", got)
}
})
t.Run("fixture rejected in skills", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "skills/example/example_test.go",
"package example\n\nvar BackupHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in skill")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
t.Fatalf("violations = %+v, want skill fixture rejection", got)
}
})
t.Run("pure rename", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
gitTestCommand(t, root, "mv", "target.go", "renamed.go")
commitDomainDiff(t, root, "rename file")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected rename violation: %+v", got)
}
})
}
func TestUnapprovedDomainPolicyAndFailurePaths(t *testing.T) {
t.Run("unused policy entry", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\npublic.example.com\nunused.example.com\n")
commitDomainDiff(t, root, "add unused policy")
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "unused.example.com") {
t.Fatalf("violations = %+v, want unused.example.com", got)
}
})
t.Run("public entry used only by fixture", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\npublic.example.com\ntest-only.example.com\n")
writeFile(t, root, "public_only_test.go",
"package sample\n\nvar BackupHost = \"test-only.example.com\"\n")
commitDomainDiff(t, root, "add test-only public policy")
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "test-only.example.com") {
t.Fatalf("violations = %+v, want test-only.example.com", got)
}
})
t.Run("changed Go parse failure", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go", "package sample\n\nfunc broken(\n")
commitDomainDiff(t, root, "break source")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "target.go" {
t.Fatalf("violations = %+v, want target.go scan-incomplete", got)
}
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
t.Fatalf("parse failure must not produce unreliable unused-policy diagnostics: %+v", unused)
}
})
t.Run("repository type loading failure", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n\n"+
"require example.com/missing v0.0.0\n\nreplace example.com/missing => ./missing\n")
writeFile(t, root, "target.go",
"package sample\n\nimport _ \"example.com/missing\"\n\n"+
"type Config struct{ Host string }\nvar config = Config{Host: \"malicious.corp.internal\"}\n")
commitDomainDiff(t, root, "break type loading")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "go.mod" {
t.Fatalf("violations = %+v, want go.mod scan-incomplete", got)
}
if !strings.Contains(got[0].Message, "load Go type information") {
t.Fatalf("message = %q, want type-loading failure", got[0].Message)
}
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
t.Fatalf("type-loading failure must not produce unreliable unused-policy diagnostics: %+v", unused)
}
})
}

View File

@@ -0,0 +1,380 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"go/ast"
"go/parser"
"go/token"
"go/types"
"sort"
"testing"
)
func scanDomainEvidence(t *testing.T, source string) []domainEvidence {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
if err != nil {
t.Fatalf("parse fixture: %v\n%s", err, source)
}
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset})
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
sort.Slice(scan.Evidence, func(i, j int) bool {
if scan.Evidence[i].Host != scan.Evidence[j].Host {
return scan.Evidence[i].Host < scan.Evidence[j].Host
}
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
})
return scan.Evidence
}
func scanTypedDomainEvidence(t *testing.T, source string) []domainEvidence {
t.Helper()
return scanTypedDomainEvidenceInPackage(t, "fixture", source)
}
func scanTypedDomainEvidenceInPackage(t *testing.T, packagePath, source string) []domainEvidence {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
if err != nil {
t.Fatalf("parse fixture: %v\n%s", err, source)
}
info := &types.Info{
Types: map[ast.Expr]types.TypeAndValue{},
Defs: map[*ast.Ident]types.Object{},
Uses: map[*ast.Ident]types.Object{},
Selections: map[*ast.SelectorExpr]*types.Selection{},
}
if _, err := (&types.Config{}).Check(packagePath, fset, []*ast.File{file}, info); err != nil {
t.Fatalf("type-check fixture: %v\n%s", err, source)
}
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset, Info: info})
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
sort.Slice(scan.Evidence, func(i, j int) bool {
if scan.Evidence[i].Host != scan.Evidence[j].Host {
return scan.Evidence[i].Host < scan.Evidence[j].Host
}
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
})
return scan.Evidence
}
func evidenceHosts(evidence []domainEvidence) []string {
hosts := make([]string, 0, len(evidence))
for _, item := range evidence {
hosts = append(hosts, item.Host)
}
return hosts
}
func TestTypedAbsoluteURLDeclarationProducesOneFinding(t *testing.T) {
evidence := scanTypedDomainEvidence(t,
"package p\nconst DomainContractE2EURL = \"https://private.corp.internal/v1\"\n")
if got := evidenceHosts(evidence); len(got) != 1 || got[0] != "private.corp.internal" {
t.Fatalf("hosts = %v, want [private.corp.internal]", got)
}
}
func TestGoDomainEvidenceTruePositives(t *testing.T) {
tests := []struct {
name string
source string
want []string
}{
{
name: "PR 1975 Feishu assignment",
source: "package p\nfunc f() { host := \"internal-api-drive-stream.feishu.cn\"; _ = host }\n",
want: []string{"internal-api-drive-stream.feishu.cn"},
},
{
name: "PR 1975 Lark assignment",
source: "package p\nfunc f() { var host string; host = \"internal-api-drive-stream.larksuite.com\"; _ = host }\n",
want: []string{"internal-api-drive-stream.larksuite.com"},
},
{
name: "uppercase snake target",
source: "package p\nfunc f() { API_HOST := \"private.corp.internal\"; _ = API_HOST }\n",
want: []string{"private.corp.internal"},
},
{
name: "typed declaration",
source: "package p\nconst APIHost string = \"attacker.zip\"\n",
want: []string{"attacker.zip"},
},
{
name: "grouped const declaration",
source: "package p\nconst (\n APIHost string = \"attacker.zip\"\n)\n",
want: []string{"attacker.zip"},
},
{
name: "grouped var declaration",
source: "package p\nvar (\n APIHost string = \"attacker.zip\"\n)\n",
want: []string{"attacker.zip"},
},
{
name: "multi assignment",
source: "package p\nfunc f() {\n" +
" APIHost, BackupHost := \"public.example.com\", \"attacker.zip\"\n" +
" _, _ = APIHost, BackupHost\n}\n",
want: []string{"attacker.zip", "public.example.com"},
},
{
name: "map semantic key",
source: "package p\nvar c = map[string]string{\"host\": \"private.corp.internal\"}\n",
want: []string{"private.corp.internal"},
},
{
name: "map semantic key assignment",
source: "package p\nfunc f() { c := map[string]string{}; c[\"host\"] = \"private.corp.internal\" }\n",
want: []string{"private.corp.internal"},
},
{
name: "host collection values",
source: "package p\nvar ALLOWED_HOSTS = []string{\"private.corp.internal\", \"attacker.zip\"}\n",
want: []string{"attacker.zip", "private.corp.internal"},
},
{
name: "host collection map keys",
source: "package p\nvar allowedHosts = map[string]struct{}{\"attacker.zip\": {}}\n",
want: []string{"attacker.zip"},
},
{
name: "host collection bool map keys",
source: "package p\nvar AllowedHosts = map[string]bool{\"api.example.com\": true}\n",
want: []string{"api.example.com"},
},
{
name: "host collection map values",
source: "package p\nvar HostsByRegion = map[string]string{\"sg\": \"api.example.com\"}\n",
want: []string{"api.example.com"},
},
{
name: "host collection map value assignment",
source: "package p\nfunc f() {\n" +
" HostsByRegion := map[string]string{}\n" +
" HostsByRegion[\"sg\"] = \"api.example.com\"\n" +
"}\n",
want: []string{"api.example.com"},
},
{
name: "static concatenation",
source: "package p\nvar APIHost = \"attacker.\" + \"zip\"\n",
want: []string{"attacker.zip"},
},
{
name: "multiline assignment",
source: "package p\nfunc f() {\n APIHost :=\n \"attacker.zip\"\n _ = APIHost\n}\n",
want: []string{"attacker.zip"},
},
{
name: "escaped hostname",
source: "package p\nvar APIHost = \"private\\u002ecorp\\u002einternal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "hex escaped hostname",
source: "package p\nvar APIHost = \"private\\x2ecorp\\x2einternal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "octal escaped hostname",
source: "package p\nvar APIHost = \"private\\056corp\\056internal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "raw hostname",
source: "package p\nvar APIHost = `private.corp.internal`\n",
want: []string{"private.corp.internal"},
},
{
name: "same-file constant reference",
source: "package p\nconst existingConst = \"private.corp.internal\"\n" +
"func f() { APIHost := existingConst; _ = APIHost }\n",
want: []string{"private.corp.internal"},
},
{
name: "absolute URL",
source: "package p\nvar message = \"https://private.corp.internal/v1\"\n",
want: []string{"private.corp.internal"},
},
{
name: "websocket URL with port",
source: "package p\nvar endpoint = \"wss://private.corp.internal:443/v1\"\n",
want: []string{"private.corp.internal"},
},
{
name: "URL userinfo query and fragment",
source: "package p\nvar endpoint = \" https://user:pass@private.corp.internal:8443/v1?q=1#result \"\n",
want: []string{"private.corp.internal"},
},
{
name: "IDN hostname",
source: "package p\nvar APIHost = \"例子.公司.cn\"\n",
want: []string{"例子.公司.cn"},
},
{
name: "case port and trailing dot normalization",
source: "package p\nvar APIHost = \"EXAMPLE.COM.:443\"\n",
want: []string{"example.com"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := evidenceHosts(scanDomainEvidence(t, tc.source))
if len(got) != len(tc.want) {
t.Fatalf("hosts = %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("hosts = %v, want %v", got, tc.want)
}
}
})
}
}
func TestGoDomainEvidenceTrueNegatives(t *testing.T) {
source := `package p
import _ "github.com/larksuite/oapi-sdk-go/v3"
var file = "archive.zip"
var event = "card.action.trigger"
var schema = "im.messages.list"
var configFile = "service.prod.json"
var version = "v1.2.3"
var email = "name@example.com"
var lowConfidence = "attacker.zip"
var downloadURL = "archive.zip/file"
var prose = "See https://private.corp.internal/v1 for details"
// https://private.corp.internal/v1
var ghost = "private.corp.internal"
var hostnameParser = "private.corp.internal"
var domainError = "private.corp.internal"
var APIHost = "localhost"
var BackupHost = "127.0.0.1"
var hosts = struct{ File string }{File: "archive.zip"}
var AllowedHosts = map[string]string{"api.example.com": "client.pem"}
func dynamicValue() string { return "private.corp.internal" }
var DynamicHost = dynamicValue()
func setAmbiguousHostMetadata() {
AllowedHosts["api.example.com"] = "client.pem"
}
`
if got := scanDomainEvidence(t, source); len(got) != 0 {
t.Fatalf("unexpected evidence: %+v", got)
}
}
func TestTypedStructFieldHostnameSemantics(t *testing.T) {
t.Run("network fields", func(t *testing.T) {
source := `package source
type Config struct { Host string }
type FeishuSource struct { Domain string }
var config = Config{Host: "api.example.com"}
var source = FeishuSource{Domain: "events.example.com"}
`
got := evidenceHosts(scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/internal/event/source",
source,
))
want := []string{"api.example.com", "events.example.com"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("hosts = %v, want %v", got, want)
}
})
t.Run("command metadata domain", func(t *testing.T) {
source := `package cmdmeta
type Meta struct { Domain string }
var meta = Meta{Domain: "im.messages"}
func update(meta *Meta) { meta.Domain = "docs.pages" }
`
if got := scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/internal/cmdmeta",
source,
); len(got) != 0 {
t.Fatalf("unexpected command metadata evidence: %+v", got)
}
})
t.Run("card action host", func(t *testing.T) {
source := `package im
type CardActionTriggerOutput struct { Host string }
var output = CardActionTriggerOutput{Host: "card.action"}
func update(output *CardActionTriggerOutput) { output.Host = "im.message" }
`
if got := scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/events/im",
source,
); len(got) != 0 {
t.Fatalf("unexpected card host evidence: %+v", got)
}
})
t.Run("unknown field ownership is conservative", func(t *testing.T) {
source := "package p\ntype Config struct { Host string }\nvar c = Config{Host: \"api.example.com\"}\n"
if got := scanDomainEvidence(t, source); len(got) != 0 {
t.Fatalf("unexpected untyped field evidence: %+v", got)
}
})
}
func TestHostnameSemanticNames(t *testing.T) {
for _, name := range []string{
"host", "HOST", "hosts", "hostname", "domains",
"api_host", "API_HOST", "ALLOWED_HOSTS",
"apiHost", "APIHost", "backupHostname",
"HostsByRegion", "APIHostsByRegion", "hostsByRegion",
} {
if !isHostnameSemanticName(name) {
t.Errorf("%q should be hostname-semantic", name)
}
}
for _, name := range []string{
"ghost", "hostnameParser", "domainError", "hostValue", "downloadURL", "endpoint", "origin",
"HostBypass", "APIHostBypass",
} {
if isHostnameSemanticName(name) {
t.Errorf("%q must not be hostname-semantic", name)
}
}
}
func TestDomainFixturePaths(t *testing.T) {
for _, path := range []string{
"internal/x/x_test.go",
"tests/cli_e2e/x.go",
"internal/x/testdata/sample.go",
} {
if !isDomainFixturePath(path) {
t.Errorf("%q should be fixture scope", path)
}
}
for _, path := range []string{
"internal/x/test_helper.go",
"examples/demo.go",
"skills/example/testdata/sample.go",
"skills/example/example_test.go",
} {
if isDomainFixturePath(path) {
t.Errorf("%q must not be fixture scope", path)
}
}
}

View File

@@ -3,7 +3,7 @@
// Command lintcheck runs repository source-contract guards that golangci-lint
// cannot express directly. It currently covers typed-error contracts and the
// resolver-owned endpoint contract.
// resolver-owned endpoint and approved-domain contracts.
//
// lintcheck lives in its own Go module under lint/ so its build-time
// dependency on golang.org/x/tools/go/packages does not leak into the
@@ -43,8 +43,10 @@ type scanner struct {
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepo(root)
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
}},
}
@@ -57,7 +59,7 @@ func main() {
"Runs every registered lint domain against repo-root (default: current directory).\n")
flag.PrintDefaults()
}
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks")
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental source-contract checks")
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
flag.Parse()

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.77",
"version": "1.0.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.77",
"version": "1.0.80",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.77",
"version": "1.0.80",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
exit 1
fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
if grep -Fq 'run.name !== "CI"' "$workflow"; then
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
exit 1
fi
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheClear clears all cache entries for the app in the given environment.
//
// POST /apps/{app_id}/cache/clearbody {env}。清空当前应用指定环境下全部缓存,用于无法定位
// 具体 key 的快速恢复;影响面大,定 high-risk-write框架自动注入 --yes 确认)。
var AppsCacheClear = common.Shortcut{
Service: appsService,
Command: "+cache-clear",
Description: "Clear all cache entries for the app in the given environment",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appCacheClearPath(appID)).
Desc("Clear all cache entries for the app in the given environment").
Body(dbEnvParams(rctx, map[string]interface{}{}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheClearPretty(w, out)
})
return nil
},
}
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
n := int64(0)
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
n = int64(f)
}
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheDelete deletes a single business cache key (idempotent).
//
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
// 故定 write非 high-risk-write、不需 --yes。目标不存在按幂等成功处理deleted_key_count=0
var AppsCacheDelete = common.Shortcut{
Service: appsService,
Command: "+cache-delete",
Description: "Delete a single business cache key (idempotent)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
DELETE(appCachePath(appID)).
Desc("Delete a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheDeletePretty(w, out)
})
return nil
},
}
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
key := common.GetString(out, "key")
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
return
}
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheGet reads a single business cache key's value + metadata.
//
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
// 按 value 字节长度算出端点不返回未命中exists=false时不带 valuettl_ms/value_size_bytes 为 null。
var AppsCacheGet = common.Shortcut{
Service: appsService,
Command: "+cache-get",
Description: "Get a business cache key's value and metadata",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appCachePath(appID)).
Desc("Get a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := projectCacheGet(data, key, rctx)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheGetPretty(w, out)
})
return nil
},
}
// projectCacheGet 组装 cache-get 输出key 回显、environment 取 resolved env、exists 直读;
// 命中时带 ttl_ms + value原始串+ value_size_bytesCLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
exists := cacheBool(data["exists"])
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"exists": exists,
}
if exists {
val := common.GetString(data, "value")
out["ttl_ms"] = cacheInt(data["ttl_ms"])
out["value_size_bytes"] = len([]byte(val))
out["value"] = val
} else {
out["ttl_ms"] = nil
out["value_size_bytes"] = nil
}
return out
}
// renderCacheGetPretty 打元信息块key/environment/exists命中再加 ttl/value_size命中时末尾展开 value。
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
exists, _ := out["exists"].(bool)
pairs := [][2]string{
{"key", common.GetString(out, "key")},
{"environment", common.GetString(out, "environment")},
{"exists", fmt.Sprintf("%v", exists)},
}
if exists {
pairs = append(pairs,
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
)
}
renderKeyValuePairs(w, pairs)
if exists {
fmt.Fprintln(w, "value:")
printCacheValuePretty(w, common.GetString(out, "value"))
}
}

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
const (
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
)
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串value 不反序列化)。
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
// ── cache-get ──
// TestAppsCacheGet_HitJSON命中时 json 默认——value 原样透传(不反序列化),
// value_size_bytes 由 CLI 按 value 字节长度算出environment 取服务端 resolved env。
func TestAppsCacheGet_HitJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
t.Fatalf("get hit data=%v", d)
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
}
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
}
// ttl_ms 必须是 JSON number透传服务端数字不得变成字符串JSON 解析后为 float64。
if _, ok := d["ttl_ms"].(float64); !ok {
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
}
// TestAppsCacheGet_HitPrettypretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
func TestAppsCacheGet_HitPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
}
// TestAppsCacheGet_Miss未命中——exists=false无 valuettl_ms / value_size_bytes 为 null。
func TestAppsCacheGet_Miss(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": false,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != false {
t.Fatalf("miss exists=%v", d["exists"])
}
if _, ok := d["value"]; ok {
t.Fatalf("miss must not carry value: %v", d)
}
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
}
}
// TestAppsCacheGet_ExistsAsString服务端把 exists 返成字符串 "true" 时仍按命中处理
// cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != true {
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("命中应带 value, got %v", d["value"])
}
}
// TestAppsCacheGet_PrettyNonJSONFallbackpretty 下 value 不是合法 JSON 时降级原样输出
// safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
}
}
// TestAppsCacheGet_TTLAsStringNormalized服务端把 ttl_ms 返成字符串 "272000" 时,
// 输出的 ttl_ms 必须归一成 JSON numbercacheInt不得随 wire 形态漂移成字符串。
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
f, ok := d["ttl_ms"].(float64)
if !ok {
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
if int(f) != 272000 {
t.Fatalf("ttl_ms = %v, want 272000", f)
}
}
// TestAppsCacheDelete_CountAsStringNormalized服务端把 deleted_key_count 返成字符串 "1" 时,
// 输出必须归一成 JSON numbercacheInt
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if _, ok := d["deleted_key_count"].(float64); !ok {
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
}
}
// TestAppsCacheGet_DryRunOmitsEnv不传 --environment 时 dry-run query 不带 env服务端自动选但带 key。
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "GET" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
}
if a.Params["key"] != "k:1" {
t.Fatalf("key must be in query, params=%v", a.Params)
}
}
// TestAppsCacheGet_DryRunWithEnv显式 --environment dev → query 带 env=dev。
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Params["env"] != "dev" {
t.Fatalf("env must be dev, params=%v", a.Params)
}
}
// TestAppsCacheGet_RequiresKey缺 --key → 校验错。
func TestAppsCacheGet_RequiresKey(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --key error")
}
}
// ── cache-delete ──
// TestAppsCacheDelete_Hit删中命中的 key → deleted_key_count=1pretty 打 "✓ cache deleted"。
func TestAppsCacheDelete_Hit(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache deleted") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_AbsentJSON目标不存在 → 幂等成功deleted_key_count=0pretty 措辞区分。
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
t.Fatalf("absent data=%v", d)
}
}
// TestAppsCacheDelete_AbsentPretty不存在 pretty 打 "✓ cache already absent"。
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "already absent") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_DryRunDELETE 方法、/cache 路由query 带 key + env。
func TestAppsCacheDelete_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "DELETE" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
t.Fatalf("params=%v", a.Params)
}
}
// ── cache-clear ──
// TestAppsCacheClear_Success清空成功 → deleted_key_count=128pretty 打 "✓ cache cleared: 128 entries (dev)"。
func TestAppsCacheClear_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: cacheClearURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
})
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheClear_RequiresConfirmationhigh-risk-write 无 --yes → 被确认门拦截。
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected confirmation gate without --yes")
}
}
// TestAppsCacheClear_DryRunBodyWithEnvdry-run POST /cache/clearbody 带 env=dev。
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "POST" || a.URL != cacheClearURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["env"] != "dev" {
t.Fatalf("body must carry env=dev, body=%v", a.Body)
}
}
// TestAppsCacheClear_DryRunBodyOmitsEnv不传 --environment → body 不带 env服务端自动选
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if _, ok := a.Body["env"]; ok {
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
}
}
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项method/url/params/body
// 复用本包规范的 dryRunAPIEnvelopeapi 现嵌在 data.api 下,见 dryrun_test.go
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
t.Helper()
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
t.Fatalf("bad dry-run json: %v\n%s", err, s)
}
return env.API[0]
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// 应用运行时缓存Cache调试命令共享件路由 + 环境 flag + 渲染。
//
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`按运行环境env→dbBranch隔离
// 环境 flag 用 cacheEnvFlag()(只 --environment不带 db 家族的旧名 --envenv 值经 dbEnv 读、
// 经 dbEnvParams 注入——get/delete 放 queryclear 放 body省略即服务端自动选分支
// appCachePath 返回缓存单 key 读/删 URLcacheGET 读、DELETE 删,靠方法区分)。
func appCachePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
}
// appCacheClearPath 返回清空指定环境缓存 URLcache/clear。
func appCacheClearPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
}
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env
// 故只注册干净的 --environment不带 db 家族那套隐藏 --env + 拒收逻辑)。
// 省略即服务端按应用多环境状态自动选分支多环境→dev非多环境→online
func cacheEnvFlag() common.Flag {
return common.Flag{
Name: "environment",
Enum: []string{"dev", "online"},
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
}
}
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中hit→miss 翻转)。
func cacheBool(v interface{}) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true")
}
return false
}
// cacheInt 把服务端下发的数值字段归一成 int64无法解析→nil。本仓惯例数值可能以字符串下发
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
// number ↔ string。归一后输出类型恒定为数字或 null消费方无需自己容忍字符串。
func cacheInt(raw interface{}) interface{} {
if f, ok := numericAsFloat(raw); ok {
return int64(f)
}
return nil
}
// resolvedEnv 取服务端回吐的 resolved env缺失时兜底成请求侧 --environment可能为空
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
if env := common.GetString(data, "env"); env != "" {
return env
}
return dbEnv(rctx)
}
// formatCacheTTL 把剩余 TTL毫秒格式化成 4m32s 这样的时长串;非数字返回 "—"。
func formatCacheTTL(ms interface{}) string {
f, ok := numericAsFloat(ms)
if !ok {
return "—"
}
return (time.Duration(int64(f)) * time.Millisecond).String()
}
// printCacheValuePretty 把 value 反序列化后缩进展开pretty 口径);非 JSON 则原样打印。
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
func printCacheValuePretty(w io.Writer, raw string) {
v := safeParseJSON(raw)
if s, ok := v.(string); ok {
fmt.Fprintln(w, s)
return
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Fprintln(w, raw)
return
}
w.Write(b)
fmt.Fprintln(w)
}

View File

@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
AppsFileUpload,
AppsFileDelete,
AppsFileQuotaGet,
AppsCacheGet,
AppsCacheDelete,
AppsCacheClear,
AppsGitCredentialInit,
AppsGitCredentialList,
AppsGitCredentialRemove,

View File

@@ -20,13 +20,14 @@ import (
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 cacheget/delete/clear
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79
func TestAppsShortcuts_Returns79(t *testing.T) {
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 82
func TestAppsShortcuts_Returns82(t *testing.T) {
got := Shortcuts()
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
if len(got) != 82 {
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
}
}

View File

@@ -4,6 +4,7 @@
package base
import (
"encoding/json"
"strings"
"testing"
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
"total": 2,
"questions": []interface{}{
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
},
},
},
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
got := stdout.String()
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
t.Fatalf("stdout=%s", got)
}
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
if !strings.Contains(got, `"visible_rule"`) {
t.Fatalf("visible_rule missing from list output: %s", got)
}
}
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
t.Fatalf("expected error for invalid questions JSON")
}
})
t.Run("visible_rule passthrough", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
},
},
},
}
reg.Register(stub)
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body struct {
Questions []map[string]interface{} `json:"questions"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
}
if len(body.Questions) != 1 {
t.Fatalf("questions=%#v", body.Questions)
}
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
if !ok {
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
}
if rule["logic"] != "and" {
t.Fatalf("visible_rule logic not preserved: %#v", rule)
}
})
}
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
Body: map[string]interface{}{
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
},
},
},
})
}
reg.Register(stub)
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
t.Fatalf("stdout=%s", got)
}
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
var body struct {
Questions []map[string]interface{} `json:"questions"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
}
if len(body.Questions) != 1 {
t.Fatalf("questions=%#v", body.Questions)
}
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
}
}
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {

View File

@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
api := common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")

View File

@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
},
Tips: []string{
"Update uses full question overwrite semantics, not a patch.",
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
api := common.NewDryRunAPI().
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")

View File

@@ -29,6 +29,7 @@ var BaseURLResolve = common.Shortcut{
Risk: "read",
Scopes: []string{},
ConditionalScopes: []string{
"base:block:read",
"base:field:read",
"base:record:read",
"wiki:node:retrieve",
@@ -40,7 +41,7 @@ var BaseURLResolve = common.Shortcut{
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
},
Tips: []string{
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -57,10 +58,34 @@ var BaseURLResolve = common.Shortcut{
return common.NewDryRunAPI().Set("error", err.Error())
}
switch classifyBaseURL(parsed) {
case "base_url":
baseToken := firstPathSegmentAfter(parsed.Path, "/base/")
if selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")); selectedBlockID != "" {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Body(map[string]interface{}{}).
Set("base_token", baseToken).
Set("selected_block_id", selectedBlockID)
}
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
case "wiki_url":
return common.NewDryRunAPI().
GET("/open-apis/wiki/v2/spaces/get_node").
dry := common.NewDryRunAPI()
selectedBlockID := strings.TrimSpace(parsed.Query().Get("table"))
if selectedBlockID == "" {
return dry.
GET("/open-apis/wiki/v2/spaces/get_node").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
}
dry.Desc("2-step: resolve the Wiki node to a Base, then identify the selected Base block")
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve the Wiki node to its underlying Base").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
dry.POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Desc("[2] List Base blocks and match selected_block_id").
Body(map[string]interface{}{})
return dry.
Set("base_token", "<obj_token from step 1>").
Set("selected_block_id", selectedBlockID)
case "record_share_url":
return common.NewDryRunAPI().
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
@@ -170,7 +195,7 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
switch classifyBaseURL(parsed) {
case "base_url":
out := resolveBaseURL(parsed)
enrichBaseResolveHint(runtime, out)
enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed))
runtime.OutFormat(out, nil, nil)
return nil
case "wiki_url":
@@ -178,6 +203,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
selection := resolveBaseURLSelection(parsed)
applyBaseURLSelection(out, selection)
enrichBaseResolveHint(runtime, out, selection)
runtime.OutFormat(out, nil, nil)
return nil
case "record_share_url":
@@ -251,24 +279,50 @@ func classifyBaseURL(u *url.URL) string {
}
func resolveBaseURL(u *url.URL) map[string]interface{} {
query := u.Query()
out := map[string]interface{}{
"input_type": "base_url",
"resource_type": "bitable",
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
}
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
out["table_id"] = tableID
}
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
out["view_id"] = viewID
}
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
out["record_id"] = recordID
}
applyBaseURLSelection(out, resolveBaseURLSelection(u))
return out
}
type baseURLSelection struct {
blockID string
viewID string
recordID string
}
func resolveBaseURLSelection(u *url.URL) baseURLSelection {
query := u.Query()
return baseURLSelection{
blockID: strings.TrimSpace(query.Get("table")),
viewID: strings.TrimSpace(query.Get("view")),
recordID: strings.TrimSpace(query.Get("record")),
}
}
func applyBaseURLSelection(out map[string]interface{}, selection baseURLSelection) {
if selection.blockID != "" {
// The Base web UI historically uses the query key "table" for the
// currently selected top-level block. Its value can identify a table,
// dashboard, workflow, or another block type. Keep it neutral until the
// block directory confirms the resource type.
out["block_id"] = selection.blockID
out["selection_source"] = "url_query"
}
}
func applyResolvedTableSelection(out map[string]interface{}, selection baseURLSelection) {
if selection.viewID != "" {
out["view_id"] = selection.viewID
}
if selection.recordID != "" {
out["record_id"] = selection.recordID
}
}
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
token := firstPathSegmentAfter(u.Path, "/wiki/")
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
@@ -368,13 +422,89 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
}
}
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
if baseToken == "" || tableID == "" {
selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id"))
if baseToken == "" || selectedBlockID == "" {
out["hint"] = resolveHint("", nil)
return
}
if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found {
out["block_type"] = block.Type
if block.Name != "" {
out["block_name"] = block.Name
}
switch block.Type {
case "table":
applyResolvedTableSelection(out, selection)
enrichResolvedTable(runtime, out, baseToken, selectedBlockID)
case "dashboard":
out["dashboard_id"] = selectedBlockID
out["hint"] = map[string]interface{}{
"next_step": "this dashboard is only the block currently selected by the URL; if the user names a different dashboard than block_name, use +dashboard-list and match that name first, otherwise use +dashboard-get to inspect this dashboard",
}
case "workflow":
out["workflow_id"] = selectedBlockID
out["hint"] = map[string]interface{}{
"next_step": "use +workflow-get to inspect the resolved workflow",
}
case "folder":
out["hint"] = map[string]interface{}{
"next_step": fmt.Sprintf("use +base-block-list --base-token %s --parent-id %s to list this folder's direct children", baseToken, selectedBlockID),
}
case "docx":
if block.DocxToken != "" {
out["docx_token"] = block.DocxToken
out["hint"] = map[string]interface{}{
"next_step": fmt.Sprintf("use docs +fetch --doc %s to read this document", block.DocxToken),
}
} else {
out["hint"] = map[string]interface{}{
"next_step": "use +base-block-list --type docx and match block_id to retrieve this document's docx_token",
}
}
default:
out["hint"] = resolveUnknownBlockHint()
}
return
}
out["hint"] = resolveUnknownBlockHint()
}
type resolvedBaseBlock struct {
ID string
Type string
Name string
DocxToken string
}
func resolveSelectedBaseBlock(runtime *common.RuntimeContext, baseToken, selectedBlockID string) (resolvedBaseBlock, bool, error) {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "blocks", "list"), nil, map[string]interface{}{})
if err != nil {
return resolvedBaseBlock{}, false, err
}
for _, item := range common.GetSlice(data, "blocks") {
row, ok := item.(map[string]interface{})
if !ok {
continue
}
block := resolvedBaseBlock{
ID: strings.TrimSpace(common.GetString(row, "id")),
Type: strings.TrimSpace(common.GetString(row, "type")),
Name: strings.TrimSpace(common.GetString(row, "name")),
DocxToken: strings.TrimSpace(common.GetString(row, "docx_token")),
}
if block.ID == selectedBlockID {
return block, true, nil
}
}
return resolvedBaseBlock{}, false, nil
}
func enrichResolvedTable(runtime *common.RuntimeContext, out map[string]interface{}, baseToken, tableID string) {
out["table_id"] = tableID
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
if err != nil {
out["hint"] = resolveHint(tableID, nil)
@@ -383,6 +513,12 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
}
func resolveUnknownBlockHint() map[string]interface{} {
return map[string]interface{}{
"next_step": "use +base-block-list and match block_id to determine whether this is a table, dashboard, workflow, folder, or docx block",
}
}
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))

View File

@@ -4,6 +4,7 @@
package base
import (
"net/http"
"strings"
"testing"
@@ -17,6 +18,9 @@ import (
func TestBaseURLResolveBaseURL(t *testing.T) {
t.Run("with coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
@@ -31,7 +35,7 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
if data["block_id"] != "tbl123" || data["selection_source"] != "url_query" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("missing Base coordinates: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
@@ -62,45 +66,213 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
}
})
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
t.Run("unconfirmed selected block stays neutral", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
if data["base_token"] != "bas123" || data["block_id"] != "tbl123" {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("unconfirmed block must not be reported as a table: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("unconfirmed block must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("unconfirmed block must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList {
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
t.Run("field endpoint does not confirm untyped block", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"},
))
fieldStub := fieldListStub("bas123", "tbl123")
fieldStub.Optional = true
fieldStub.OnMatch = func(_ *http.Request) {
t.Fatalf("field endpoint must not be used to infer selected block type")
}
reg.Register(fieldStub)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "tbl123" {
t.Fatalf("unexpected block coordinates: %#v", data)
}
if _, ok := data["block_type"]; ok {
t.Fatalf("field endpoint must not confirm block type without block directory: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("field endpoint must not promote an untyped block to table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("untyped block must not expose table-only view_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when block type is unconfirmed: %#v", hint)
}
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("dashboard selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_dashboard&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "blk_dashboard" || data["selection_source"] != "url_query" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" || data["block_name"] != "Sales" {
t.Fatalf("unexpected dashboard coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("dashboard must not be reported as table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "+dashboard-get") || !strings.Contains(nextStep, "+dashboard-list") || !strings.Contains(nextStep, "different dashboard than block_name") {
t.Fatalf("unexpected dashboard hint: %#v", hint)
}
})
t.Run("workflow selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "wkf_notify", "type": "workflow", "name": "Notify"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=wkf_notify&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "wkf_notify" || data["block_type"] != "workflow" || data["workflow_id"] != "wkf_notify" {
t.Fatalf("unexpected workflow coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("workflow must not be reported as table_id: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("workflow must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("workflow must not expose table-only record_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if !strings.Contains(hint["next_step"].(string), "+workflow-get") {
t.Fatalf("unexpected workflow hint: %#v", hint)
}
})
t.Run("folder selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "bfl_projects", "type": "folder", "name": "Projects"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=bfl_projects&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "bfl_projects" || data["block_type"] != "folder" || data["block_name"] != "Projects" {
t.Fatalf("unexpected folder coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("folder must not be reported as table_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "+base-block-list --base-token bas123 --parent-id bfl_projects") || strings.Contains(nextStep, "determine whether") {
t.Fatalf("unexpected folder hint: %#v", hint)
}
})
t.Run("docx selected through table query key", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec", "docx_token": "docx123"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_doc&view=vew_stale&record=rec_stale", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["block_id"] != "blk_doc" || data["block_type"] != "docx" || data["block_name"] != "Spec" || data["docx_token"] != "docx123" {
t.Fatalf("unexpected docx coordinates: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("docx must not be reported as table_id: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep := hint["next_step"].(string)
if !strings.Contains(nextStep, "docs +fetch --doc docx123") || strings.Contains(nextStep, "determine whether") {
t.Fatalf("unexpected docx hint: %#v", hint)
}
})
}
func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}) *httpmock.Stub {
items := make([]interface{}, 0, len(blocks))
for _, block := range blocks {
items = append(items, block)
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": items,
"total": len(items),
},
},
}
}
func TestBaseURLResolveWikiURL(t *testing.T) {
t.Run("bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bas123",
"title": "Demo Base",
},
},
},
})
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
@@ -114,6 +286,57 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
}
})
t.Run("bitable with table coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew123&record=rec123",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["block_id"] != "tbl123" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected Wiki Base table coordinates: %#v", data)
}
})
t.Run("bitable with dashboard selection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
reg.Register(baseBlockListResolveStub("bas123",
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/wiki/wik123?table=blk_dashboard&view=vew_stale&record=rec_stale",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["block_id"] != "blk_dashboard" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" {
t.Fatalf("unexpected Wiki Base dashboard coordinates: %#v", data)
}
if _, ok := data["view_id"]; ok {
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
}
if _, ok := data["record_id"]; ok {
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
}
})
t.Run("non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -136,6 +359,23 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
})
}
func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=" + wikiToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": baseToken,
"title": title,
},
},
},
}
}
func TestBaseURLResolveRecordShareURL(t *testing.T) {
t.Run("enriched", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)

View File

@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
},
},
{
name: "form question create visible_rule",
shortcut: BaseFormQuestionsCreate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
name: "form question update visible_rule",
shortcut: BaseFormQuestionsUpdate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
name: "record search json",
shortcut: BaseRecordSearch,
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"Update uses full question overwrite semantics",
"run +form-questions-list first",
"include existing values you want to keep",
"pass null or omit to clear",
}
for _, want := range wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
"full question overwrite semantics, not a patch",
"Run +form-questions-list first",
"title/description/required/option_display_mode/visible_rule",
"Omitted fields reset to defaults",
"empty strings, null, and empty arrays are written as empty/clear",
}
for _, want := range wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string

View File

@@ -250,7 +250,7 @@ var CalendarAgenda = common.Shortcut{
}
}
backfillDescriptionRich(e)
collapseDescription(e)
filtered = append(filtered, e)
}

View File

@@ -32,8 +32,8 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if descriptionRich := descriptionRichToSend(runtime); descriptionRich != "" {
eventData["description_rich"] = descriptionRich
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -120,8 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -234,7 +233,7 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionRichImages(runtime, calendarId); err != nil {
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}

View File

@@ -170,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
backfillDescriptionRich(out)
collapseDescription(out)
return out, nil
}

View File

@@ -988,8 +988,9 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
// The deprecated, hidden --description folds into description_rich; the CLI
// never sends the plain description field (mutually exclusive downstream).
// --description is the unified field, treated as rich text and sent as
// description_rich; the CLI never sends the plain description field
// (mutually exclusive downstream).
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
@@ -1411,18 +1412,17 @@ func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Read keeps both fields: description (plain) and description_rich (rich).
if !strings.Contains(out, "\"description\": \"[测试]\\n友情提醒\"") {
t.Errorf("expected plain description retained, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\": \"友情提醒\"") {
t.Errorf("expected rich description surfaced, got: %s", out)
// Read exposes a single unified description field: it carries the rich
// (Markdown) value when present, and the plain text otherwise. The internal
// description_rich key is never surfaced.
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description retained for plain-only event, got: %s", out)
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
}
@@ -3438,7 +3438,8 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
func TestGet_UnifiesDescriptionRich(t *testing.T) {
// Read keeps both description (plain) and description_rich (rich).
// Read exposes a single unified description field carrying the rich value
// when present, and the plain text otherwise; description_rich is dropped.
t.Run("rich present", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
@@ -3462,17 +3463,16 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"[表格]\"") {
t.Errorf("expected plain description retained, got: %s", out)
if !strings.Contains(out, "| a | b |") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\":") {
t.Errorf("expected description_rich in output, got: %s", out)
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
// When only a plain description exists, description_rich is backfilled from it
// and the plain description is still returned.
t.Run("only plain backfills rich", func(t *testing.T) {
// When only a plain description exists, it is surfaced under description.
t.Run("only plain surfaces under description", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -3495,10 +3495,10 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description retained, got: %s", out)
t.Errorf("expected plain description surfaced, got: %s", out)
}
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
}

View File

@@ -29,8 +29,7 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -72,7 +71,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
return err
}
if !hasCalendarUpdateOperation(runtime) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
}
return nil
}
@@ -114,10 +113,7 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body["summary"] = runtime.Str("summary")
hasFields = true
}
if runtime.Cmd.Flags().Changed("description-rich") {
body["description_rich"] = runtime.Str("description-rich")
hasFields = true
} else if runtime.Cmd.Flags().Changed("description") {
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
hasFields = true
}
@@ -362,11 +358,8 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
// Upload any local images referenced in --description-rich and rewrite them
// to drive URLs before the description is sent (the service cannot read
// local files).
if runtime.Cmd.Flags().Changed("description-rich") {
if err := resolveDescriptionRichImages(runtime, calendarID); err != nil {
if runtime.Cmd.Flags().Changed("description") {
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
return err
}
}
@@ -443,19 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
// Surface both description fields on read: description holds plain text,
// description_rich holds the rich (Markdown) version, backfilled from plain
// when the service returned no rich value.
description, _ := event["description"].(string)
if description != "" {
result["description"] = description
}
descriptionRich, _ := event["description_rich"].(string)
if descriptionRich == "" {
descriptionRich = description
}
if descriptionRich != "" {
result["description_rich"] = descriptionRich
if rich, _ := event["description_rich"].(string); rich != "" {
result["description"] = rich
} else if plain, _ := event["description"].(string); plain != "" {
result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start

View File

@@ -27,8 +27,8 @@ const calendarMediaParentType = "calendar"
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description-rich")
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description")
if md == "" || !strings.Contains(md, "![") {
return nil
}
@@ -37,8 +37,8 @@ func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID str
return err
}
if changed {
if err := runtime.Cmd.Flags().Set("description-rich", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description-rich after image upload: %v", err).WithCause(err)
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
}
}
return nil
@@ -85,8 +85,8 @@ func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt stri
safePath, err := validate.SafeInputPath(localPath)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description-rich image %q could not be read: %v", src, err).
WithParam("--description-rich").
"--description image %q could not be read: %v", src, err).
WithParam("--description").
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
WithCause(err)
}
@@ -157,7 +157,7 @@ func localImagePath(src string) string {
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.larkoffice.com"
host := "internal-api-drive-stream.feishu.cn"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}

View File

@@ -68,7 +68,7 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "larkoffice"},
{core.BrandFeishu, "feishu.cn"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
@@ -158,7 +158,7 @@ func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description-rich", "![pic](./pic.png)",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
@@ -233,7 +233,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description-rich", "![pic](./pic.png)",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
@@ -253,7 +253,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
}
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
// yields a typed --description-rich validation error before any API call.
// yields a typed --description validation error before any API call.
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
@@ -263,7 +263,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description-rich", "![p](/etc/hosts)",
"--description", "![p](/etc/hosts)",
"--as", "bot",
}, f, stdout)
if runErr == nil {
@@ -273,7 +273,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
if !errors.As(runErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
}
if ve.Param != "--description-rich" {
t.Errorf("param = %q, want --description-rich", ve.Param)
if ve.Param != "--description" {
t.Errorf("param = %q, want --description", ve.Param)
}
}

View File

@@ -30,20 +30,23 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
func backfillDescriptionRich(event map[string]interface{}) {
func collapseDescription(event map[string]interface{}) {
if event == nil {
return
}
if descRich, _ := event["description_rich"].(string); descRich == "" {
if desc, _ := event["description"].(string); desc != "" {
event["description_rich"] = desc
}
rich, _ := event["description_rich"].(string)
plain, _ := event["description"].(string)
delete(event, "description_rich")
switch {
case rich != "":
event["description"] = rich
case plain != "":
event["description"] = plain
default:
delete(event, "description")
}
}
func descriptionRichToSend(runtime *common.RuntimeContext) string {
if v := runtime.Str("description-rich"); v != "" {
return v
}
func descriptionToSend(runtime *common.RuntimeContext) string {
return runtime.Str("description")
}

View File

@@ -25,18 +25,6 @@ func (r *scopeCheckTokenResolver) ResolveToken(ctx context.Context, req credenti
return r.result, r.err
}
type scopeCheckAccountResolver struct {
appID string
}
func (r scopeCheckAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return &credential.Account{AppID: r.appID}, nil
}
func newScopeCheckCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
return credential.NewCredentialProvider(nil, scopeCheckAccountResolver{appID: appID}, tokenResolver, nil)
}
// TestEnhancePermissionError_TypedPermissionErrorRouted pins typed routing:
// an *errs.PermissionError gets enhanced regardless of its Message text,
// decoupling this helper from canonical-message rewrites that would
@@ -118,7 +106,7 @@ func TestEnhancePermissionError_PermissionErrorGetsScopeHint(t *testing.T) {
func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
f := &cmdutil.Factory{
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: context.Canceled}),
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: context.Canceled}, nil),
}
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})
@@ -136,9 +124,9 @@ func TestCheckShortcutScopes_PropagatesContextCancellation(t *testing.T) {
// command for human consumers.
func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
f := &cmdutil.Factory{
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{
result: &credential.TokenResult{Token: "t", Scopes: "im:message:read calendar:calendar:read"},
}),
}, nil),
}
required := []string{"im:message:read", "drive:drive:read", "docx:document:read"}
@@ -180,7 +168,7 @@ func TestCheckShortcutScopes_ReturnsTypedPermissionError(t *testing.T) {
func TestCheckShortcutScopes_IgnoresNonContextTokenErrors(t *testing.T) {
f := &cmdutil.Factory{
Credential: newScopeCheckCredentialProvider("app-1", &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}),
Credential: credential.NewCredentialProvider(nil, nil, &scopeCheckTokenResolver{err: errors.New("token cache unavailable")}, nil),
}
err := checkShortcutScopes(f, context.Background(), core.AsUser, &core.CliConfig{AppID: "app-1"}, []string{"im:message:read"})

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const botSearchURL = "/open-apis/bot/v4/bot/search"
const (
maxBotSearchQueryChars = 50
maxBotSearchChatIDs = 100
maxBotSearchPageSize = 30
)
type botSearchAPIRequest struct {
Query string `json:"query,omitempty"`
Filter *botSearchAPIFilter `json:"filter,omitempty"`
}
// HasChatter uses omitempty: validation rejects =false, so a set field is always
// true and an unset field stays out of the request entirely.
type botSearchAPIFilter struct {
ChatIDs []string `json:"chat_ids,omitempty"`
HasChatter bool `json:"has_chatter,omitempty"`
}
type botSearchAPIData struct {
Items []botSearchAPIItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
Notice string `json:"notice"`
}
type botSearchAPIItem struct {
ID string `json:"id"`
DisplayInfo string `json:"display_info"`
MetaData botSearchAPIMeta `json:"meta_data"`
}
type botSearchAPIMeta struct {
TenantID string `json:"tenant_id"`
EnableJoinGroup bool `json:"enable_join_group"`
ChatID string `json:"chat_id"`
IsAgent bool `json:"is_agent"`
}
type searchBot struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// ChatID is the caller's P2P chat with the bot.
ChatID string `json:"chat_id"`
EnableJoinGroup bool `json:"enable_join_group"`
IsAgent bool `json:"is_agent"`
TenantID string `json:"tenant_id,omitempty"`
MatchSegments []string `json:"match_segments"`
}
// PageToken is decoded from the response but deliberately not surfaced, matching
// searchUserResponse: neither search command paginates. Callers narrow the query
// instead, so handing out a token that no flag accepts would only mislead.
type searchBotResponse struct {
Bots []searchBot `json:"bots"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
var ContactSearchBot = common.Shortcut{
Service: "contact",
Command: "+search-bot",
Description: "Search bots (apps) by keyword — across the tenant, or inside specific chats (requires --as user)",
Risk: "read",
Scopes: []string{"search:bot"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"},
{Name: "chat-ids", Desc: "search within specific chats (CSV of chat_id; ≤ 100)"},
{Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotSearch(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
api := common.NewDryRunAPI()
for _, q := range parseAndDedupQueries(raw) {
body := &botSearchAPIRequest{Query: q, Filter: filter}
api.POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
}
return api
}
body, err := buildBotSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(botSearchURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
},
Execute: executeBotSearch,
}
// executeBotSearch dispatches to single-query or fanout mode.
func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeBotSearchFanout(ctx, runtime)
}
return executeBotSearchSingle(ctx, runtime)
}
// botSearchKeywordRequiredError names every flag that can satisfy the keyword
// requirement. Naming only --query would tell an agent that --queries is not a
// way out, which it is.
func botSearchKeywordRequiredError() error {
return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"},
errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"},
)
}
// botSearchHasChattedFalseError is raised from two places — with and without a
// keyword — so the wording stays in one spot.
//
// Agents passing =false almost always mean "do not filter", but the API reads it
// as "must NOT match". A hard error prevents silent wrong results.
func botSearchHasChattedFalseError() error {
return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)").
WithParam("--has-chatted")
}
func validateBotSearch(runtime *common.RuntimeContext) error {
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
query := strings.TrimSpace(runtime.Str("query"))
explicitFalseHasChatted := runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted")
if queriesRaw != "" {
if query != "" {
return common.ValidationErrorf("--query and --queries are mutually exclusive").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
)
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
WithParam("--queries")
}
if len(queries) > maxFanoutQueries {
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
WithParam("--queries")
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxBotSearchQueryChars {
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxBotSearchQueryChars).
WithParam("--queries")
}
}
} else if query == "" {
// No keyword at all. An explicit =false is the more specific mistake, so
// report it instead of sending the caller off to add a keyword only to hit
// this on the next attempt. +search-user lands here too: a Changed bool
// counts as search input for its "at least one" gate, so the =false check
// is what it reaches next.
//
// Scoped to the no-keyword case on purpose. Hoisting it above the keyword
// checks would let it mask the mutual-exclusion and length errors, which
// +search-user reports first when a keyword is present.
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
return botSearchKeywordRequiredError()
} else if utf8.RuneCountInString(query) > maxBotSearchQueryChars {
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars).
WithParam("--query")
}
if _, err := parseBotSearchChatIDs(runtime); err != nil {
return err
}
if explicitFalseHasChatted {
return botSearchHasChattedFalseError()
}
if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize {
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize).
WithParam("--page-size")
}
return nil
}
func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) {
raw := strings.TrimSpace(runtime.Str("chat-ids"))
if raw == "" {
return nil, nil
}
parts := common.SplitCSV(raw)
if len(parts) == 0 {
return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw).
WithParam("--chat-ids")
}
// Normalize before deduping, then check the cap against the deduped list —
// the same order common.resolveOpenIDs uses for --user-ids. Doing it the other
// way would spend the server's 100-entry budget on duplicates, and would let
// 101 copies of one chat be rejected here while the sibling command accepts
// them. Normalization matters too: a chat URL and a bare chat_id can name the
// same chat.
seen := make(map[string]struct{}, len(parts))
chatIDs := make([]string, 0, len(parts))
for _, part := range parts {
normalized, err := common.ValidateChatIDTyped("--chat-ids", part)
if err != nil {
return nil, err
}
if _, dup := seen[normalized]; dup {
continue
}
seen[normalized] = struct{}{}
chatIDs = append(chatIDs, normalized)
}
if len(chatIDs) > maxBotSearchChatIDs {
return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs).
WithParam("--chat-ids")
}
return chatIDs, nil
}
// buildBotSearchFilter reads the scope flags shared by single and fanout search.
// A nil filter means "no scope": an empty filter object is not the same request.
func buildBotSearchFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) {
filter := &botSearchAPIFilter{}
hasFilter := false
chatIDs, err := parseBotSearchChatIDs(runtime)
if err != nil {
return nil, err
}
if len(chatIDs) > 0 {
filter.ChatIDs = chatIDs
hasFilter = true
}
if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") {
filter.HasChatter = true
hasFilter = true
}
if !hasFilter {
return nil, nil
}
return filter, nil
}
func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) {
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return nil, err
}
return &botSearchAPIRequest{
Query: strings.TrimSpace(runtime.Str("query")),
Filter: filter,
}, nil
}
// botSearchStdoutCarriesEnvelope reports whether the chosen format puts the
// response envelope — notice, has_more, and in fanout mode queries[] — into
// stdout. Only json does; pretty, table, csv and ndjson render rows only, so
// every piece of "this result is not the whole answer" metadata would vanish and
// the caller would read a truncated result as a complete one. For those formats
// the metadata goes to stderr, which keeps stdout pipe-clean. A --jq expression
// can still project it away, but that is the caller's explicit choice.
func botSearchStdoutCarriesEnvelope(format string) bool {
return format == "json" || format == ""
}
func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildBotSearchBody(runtime)
if err != nil {
return err
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return err
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return err
}
bots := projectBots(respData)
out := searchBotResponse{
Bots: bots,
HasMore: respData.HasMore,
Notice: respData.Notice,
}
runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) {
if len(bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotRows(bots))
})
if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice)
}
if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) {
fmt.Fprintln(runtime.IO().ErrOut,
"\nhint: more matches exist; narrow with --has-chatted or a more specific --query")
}
return nil
}
func decodeBotSearchAPIData(data map[string]interface{}) (*botSearchAPIData, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, contactInvalidResponseError("marshal bot search response data failed").WithCause(err)
}
var out botSearchAPIData
if err := json.Unmarshal(raw, &out); err != nil {
return nil, contactInvalidResponseError("decode bot search response data failed").WithCause(err)
}
return &out, nil
}
func projectBots(data *botSearchAPIData) []searchBot {
if data == nil {
return []searchBot{}
}
bots := make([]searchBot, 0, len(data.Items))
for i := range data.Items {
item := &data.Items[i]
name, description, segments := parseBotDisplayInfo(item.DisplayInfo)
bots = append(bots, searchBot{
OpenID: item.ID,
Name: name,
Description: description,
ChatID: item.MetaData.ChatID,
EnableJoinGroup: item.MetaData.EnableJoinGroup,
IsAgent: item.MetaData.IsAgent,
TenantID: item.MetaData.TenantID,
MatchSegments: segments,
})
}
return bots
}
func stripHighlightTags(value string) string {
value = strings.ReplaceAll(value, "<h>", "")
return strings.ReplaceAll(value, "</h>", "")
}
func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) {
matchSegments = make([]string, 0)
for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) {
// The capture can still carry a tag: the non-greedy pattern pairs a
// stray `<h>` with the next `</h>`. Strip it so a segment reads like the
// name and description it came from, and drop a highlight with no text.
segment := html.UnescapeString(stripHighlightTags(match[1]))
if strings.TrimSpace(segment) == "" {
continue
}
matchSegments = append(matchSegments, segment)
}
lines := strings.Split(raw, "\n")
stripTags := func(value string) string {
return strings.TrimSpace(html.UnescapeString(stripHighlightTags(value)))
}
// nameLine records which line the name came from, so the description is read
// from the line after it. Reading lines[1] unconditionally echoes the name
// back as its own description whenever line 0 is blank, and drops the real
// description with it.
nameLine := -1
if len(lines) > 0 {
if candidate := stripTags(lines[0]); candidate != "" {
name = candidate
nameLine = 0
}
}
if name == "" {
for i, line := range lines {
if candidate := stripTags(line); candidate != "" {
name = candidate
nameLine = i
break
}
}
}
if nameLine >= 0 && nameLine+1 < len(lines) {
description = stripTags(lines[nameLine+1])
}
return name, description, matchSegments
}
// map[] shape is required by output.PrintTable.
func prettyBotRows(bots []searchBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,289 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// Bot fanout reuses the user fanout's query parsing, concurrency limit and
// response summary types.
type botFanoutResult struct {
Index int
Query string
Bots []searchBot
HasMore bool
Notice string
ErrMsg string // empty = success
Err error // original failure, kept for typed propagation
}
// runOneBotQuery converts one fanout request into either bots or an error summary.
func runOneBotQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *botSearchAPIFilter) botFanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a request;
// in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return botFanoutErrorResult(index, query, err)
}
body := &botSearchAPIRequest{Query: query}
if filter != nil {
body.Filter = filter
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: botSearchURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return botFanoutErrorResult(index, query, err)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
respData, err := decodeBotSearchAPIData(data)
if err != nil {
return botFanoutErrorResult(index, query, err)
}
return botFanoutResult{
Index: index,
Query: query,
Bots: projectBots(respData),
HasMore: respData.HasMore,
Notice: respData.Notice,
}
}
// botFanoutErrorResult records a failed fanout query without stopping other workers.
func botFanoutErrorResult(index int, query string, err error) botFanoutResult {
if err == nil {
return botFanoutResult{Index: index, Query: query}
}
return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
}
func botFanoutContextError(err error) error {
subtype := errs.SubtypeNetworkTransport
message := "bot search fanout cancelled"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
message = "bot search fanout deadline exceeded"
}
return errs.NewNetworkError(subtype, "%s", message).WithCause(err)
}
func botFanoutPanicError(query string, recovered any) error {
err := errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q panicked: %v", query, recovered)
if cause, ok := recovered.(error); ok {
return err.WithCause(cause)
}
return err
}
// Terminal failures invalidate the batch; API and network failures remain
// eligible for partial-success reporting.
func botFanoutTerminalError(results []botFanoutResult) error {
for _, result := range results {
if result.Err == nil {
continue
}
if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) {
return botFanoutContextError(result.Err)
}
problem, ok := errs.ProblemOf(result.Err)
if !ok {
return errs.NewInternalError(errs.SubtypeUnknown,
"bot search query %q failed with an unclassified error: %v", result.Query, result.Err).
WithCause(result.Err)
}
if problem.Category != errs.CategoryAPI && problem.Category != errs.CategoryNetwork {
return result.Err
}
}
return nil
}
type fanoutBot struct {
searchBot
MatchedQuery string `json:"matched_query"`
}
type botFanoutResponse struct {
Bots []fanoutBot `json:"bots"`
Queries []querySummary `json:"queries"`
Notice string `json:"notice,omitempty"`
}
// buildBotFanoutResponse flattens recoverable results in query order. Terminal
// errors fail the batch even when another query succeeded.
func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) {
if err := botFanoutTerminalError(results); err != nil {
return nil, err
}
indexed := make([]botFanoutResult, len(queries))
for _, r := range results {
indexed[r.Index] = r
}
out := &botFanoutResponse{
Bots: make([]fanoutBot, 0),
Queries: make([]querySummary, 0, len(queries)),
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErr error
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
Notice: r.Notice,
})
if r.ErrMsg != "" {
failed++
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErr = r.Err
}
continue
}
if out.Notice == "" {
out.Notice = r.Notice
}
for _, b := range r.Bots {
out.Bots = append(out.Bots, fanoutBot{searchBot: b, MatchedQuery: queries[i]})
}
}
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
return nil, contactFanoutAllFailedError(firstErr, msg)
}
return out, nil
}
func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) error {
queries := parseAndDedupQueries(runtime.Str("queries"))
filter, err := buildBotSearchFilter(runtime)
if err != nil {
return err
}
results := make([]botFanoutResult, len(queries))
var wg sync.WaitGroup
sem := make(chan struct{}, fanoutConcurrency)
schedule:
for i, q := range queries {
select {
case sem <- struct{}{}:
case <-ctx.Done():
for j := i; j < len(queries); j++ {
results[j] = botFanoutErrorResult(j, queries[j], ctx.Err())
}
break schedule
}
wg.Add(1)
go func(i int, q string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
err := botFanoutPanicError(q, r)
results[i] = botFanoutResult{
Index: i,
Query: q,
ErrMsg: contactFanoutErrorSummary(err),
Err: err,
}
}
}()
results[i] = runOneBotQuery(ctx, runtime, i, q, filter)
}(i, q)
}
wg.Wait()
resp, err := buildBotFanoutResponse(queries, results)
if err != nil {
return err
}
failed, hasMoreCount := 0, 0
for _, qs := range resp.Queries {
if qs.Error != "" {
failed++
}
if qs.HasMore {
hasMoreCount++
}
}
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Bots)}, func(w io.Writer) {
if len(resp.Bots) == 0 {
fmt.Fprintln(w, "No bots found.")
return
}
output.PrintTable(w, prettyBotFanoutRows(resp.Bots))
})
if isFanoutSummaryFormat(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total matches; %d failed, %d with has_more\n",
len(queries), len(resp.Bots), failed, hasMoreCount)
}
// The counts above say how many queries failed but not which, and only the
// json envelope carries queries[].error / queries[].notice. Without this an
// agent reading csv or a table sees "1 failed" with no way to learn the
// keyword or the reason, and a notice disappears entirely.
if !botSearchStdoutCarriesEnvelope(runtime.Format) {
for _, qs := range resp.Queries {
if qs.Error != "" {
fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error)
}
if qs.Notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice)
}
if qs.HasMore {
fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query)
}
}
}
return nil
}
func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(bots))
for _, bot := range bots {
rows = append(rows, map[string]interface{}{
"matched_query": bot.MatchedQuery,
"name": bot.Name,
"description": common.TruncateStr(bot.Description, 50),
"is_agent": bot.IsAgent,
"enable_join_group": bot.EnableJoinGroup,
"open_id": bot.OpenID,
})
}
return rows
}

View File

@@ -0,0 +1,684 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBotFanoutErrorResultNilErrorIsSuccess(t *testing.T) {
r := botFanoutErrorResult(3, "会议助手", nil)
if r.ErrMsg != "" || r.Err != nil {
t.Fatalf("nil error must stay a success result: %+v", r)
}
if r.Index != 3 || r.Query != "会议助手" {
t.Fatalf("index/query must survive: %+v", r)
}
}
func TestBotFanoutAssembleOrderAndShape(t *testing.T) {
results := []botFanoutResult{
{Index: 1, Query: "日报", Bots: []searchBot{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}},
{Index: 2, Query: "审批", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报", "审批"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Results are emitted in query order even though the workers finished out of
// order, and a failed query contributes no rows.
wantRows := []struct {
openID, matched string
}{{"ou_a1", "会议"}, {"ou_a2", "会议"}, {"ou_b", "日报"}}
if len(resp.Bots) != len(wantRows) {
t.Fatalf("bots length: got %d, want %d", len(resp.Bots), len(wantRows))
}
for i, w := range wantRows {
if resp.Bots[i].OpenID != w.openID || resp.Bots[i].MatchedQuery != w.matched {
t.Errorf("bots[%d]: got %+v, want %s/%s", i, resp.Bots[i], w.openID, w.matched)
}
}
want := []querySummary{
{Query: "会议"},
{Query: "日报", HasMore: true},
{Query: "审批", Error: "API 1: nope"},
}
if len(resp.Queries) != len(want) {
t.Fatalf("queries length: got %d, want %d (every query is enumerated)", len(resp.Queries), len(want))
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestBotFanoutAssembleAllFailedReturnsTypedError(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", ErrMsg: "API 99991663: rate limit", Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663)},
{Index: 1, Query: "日报", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("expected an error when every query fails")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's classification must survive, so the caller can tell a
// rate limit apart from a transport fault.
if problem.Code != 99991663 || problem.Subtype != errs.SubtypeRateLimit {
t.Errorf("problem: got %d/%s, want 99991663/%s", problem.Code, problem.Subtype, errs.SubtypeRateLimit)
}
// Agents grep the count and the first failure out of this message.
for _, want := range []string{"all 2 queries failed", "rate limit"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
{Index: 1, Query: "日报", ErrMsg: "API 1: nope"},
}
resp, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err != nil {
t.Fatalf("one failure out of two must not fail the call: %v", err)
}
if len(resp.Bots) != 1 || resp.Queries[1].Error == "" {
t.Fatalf("partial failure shape: %+v", resp)
}
}
func TestBotFanoutTerminalContextOverridesPartialSuccess(t *testing.T) {
tests := []struct {
name string
err error
wantSubtype errs.Subtype
}{
{name: "cancelled", err: context.Canceled, wantSubtype: errs.SubtypeNetworkTransport},
{name: "deadline", err: context.DeadlineExceeded, wantSubtype: errs.SubtypeNetworkTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results := []botFanoutResult{
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
botFanoutErrorResult(1, "日报", tt.err),
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("terminal context error must fail the batch after a partial success")
}
if !errors.Is(err, tt.err) {
t.Fatalf("error must preserve %v as its cause: %v", tt.err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != tt.wantSubtype {
t.Fatalf("problem: got %+v, want network/%s", problem, tt.wantSubtype)
}
})
}
}
func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// has_more is per query in the sidecar; a single top-level flag would hide
// which keyword was truncated.
if _, ok := envelope["has_more"]; ok {
t.Fatalf("fanout must not surface a top-level has_more: %s", raw)
}
if !envelope["queries"].([]interface{})[0].(map[string]interface{})["has_more"].(bool) {
t.Fatalf("per-query has_more lost: %s", raw)
}
}
func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) {
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议"}})
if err != nil {
t.Fatalf("build: %v", err)
}
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"bots":[]`) {
t.Fatalf("empty bots must serialize as [], not null: %s", raw)
}
}
func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) {
rows := prettyBotFanoutRows([]fanoutBot{{
searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80)},
MatchedQuery: "会议",
}})
if len(rows) != 1 {
t.Fatalf("rows: %d", len(rows))
}
if rows[0]["matched_query"] != "会议" {
t.Errorf("matched_query missing: %+v", rows[0])
}
if got := rows[0]["description"].(string); len([]rune(got)) > 51 {
t.Errorf("description must be truncated like the single-search table: %d runes", len([]rune(got)))
}
}
func TestBotFanoutValidationRejectsQueryAndQueriesTogether(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", "会议")
setBotSearchFlag(t, cmd, "queries", "会议,日报")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if err == nil {
t.Fatal("expected mutual-exclusion error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("message: %v", err)
}
}
func TestBotFanoutValidationLimits(t *testing.T) {
tests := []struct {
name string
queries string
wantParam string
}{
{name: "nothing parses", queries: " , , ", wantParam: "--queries"},
{name: "over the entry cap", queries: strings.TrimSuffix(strings.Repeat("q%d,", maxFanoutQueries+1), ","), wantParam: "--queries"},
{name: "entry too long", queries: strings.Repeat("会", maxBotSearchQueryChars+1), wantParam: "--queries"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
queries := tt.queries
if strings.Contains(queries, "%d") {
parts := make([]string, 0, maxFanoutQueries+1)
for i := 0; i <= maxFanoutQueries; i++ {
parts = append(parts, fmt.Sprintf("q%d", i))
}
queries = strings.Join(parts, ",")
}
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", queries)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
assertBotSearchValidationProblem(t, validateBotSearch(runtime), tt.wantParam)
})
}
}
// --queries alone is enough: the single-search "--query is required" rule must not
// leak into fanout mode.
func TestBotFanoutValidationQueriesAloneIsValid(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议助手,日报助手")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBotFanoutFilterAppliedToEveryQuery(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--has-chatted", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) != 2 {
t.Fatalf("expected one request per query, got %d", len(stub.CapturedBodies))
}
seen := make(map[string]bool, len(stub.CapturedBodies))
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
seen[fmt.Sprint(body["query"])] = true
filter, ok := body["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true {
t.Fatalf("filter must ride along with every query: %#v", body)
}
}
for _, q := range []string{"会议", "日报"} {
if !seen[q] {
t.Fatalf("query %q never issued; saw %v", q, seen)
}
}
}
func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
dedupStub := botSearchStub(botSearchURL+"?page_size=20", "")
dedupStub.Reusable = true
registry.Register(dedupStub)
// " 会议 " and "会议" collapse to one query; the duplicate must not double the
// requests or the rows.
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", " 会议 ,会议", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if len(envelope.Data.Queries) != 1 || envelope.Data.Queries[0].Query != "会议" {
t.Fatalf("dedup failed: %+v", envelope.Data.Queries)
}
for _, bot := range envelope.Data.Bots {
if bot.MatchedQuery != "会议" {
t.Fatalf("matched_query fidelity: %+v", bot)
}
}
}
func TestBotFanoutConcurrencyCap(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
var inFlight, peak int32
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
}
registry.Register(stub)
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > fanoutConcurrency {
t.Errorf("concurrency peak = %d, want <= %d", peak, fanoutConcurrency)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want >= 2 so the test actually observes parallelism", peak)
}
}
func TestBotFanoutPanicFailsBatch(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
panicCause := errors.New("synthetic test panic")
boom := botSearchStub(botSearchURL, "")
boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) }
boom.OnMatch = func(req *http.Request) { panic(panicCause) }
registry.Register(boom)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a panicking query must fail the batch")
}
if !errors.Is(err, panicCause) {
t.Fatalf("panic cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem: got %+v, want internal/%s", problem, errs.SubtypeUnknown)
}
if stdout.Len() != 0 {
t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String())
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String())
}
}
}
func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL,
Reusable: true,
Status: 500,
Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("every query failing must surface as a command error")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("expected a typed problem, got %T: %v", err, err)
}
// The first failure's upstream status and the all-failed mode must both survive,
// so a caller can classify instead of seeing a generic internal error.
for _, want := range []string{"500", "all 2 queries failed"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("message must contain %q; got %v", want, err)
}
}
}
func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
var envelope struct {
Data botFanoutResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
const wantNotice = "The query is too long and has been truncated to the first 50 characters for search."
// Assert the notice itself, not just that some row survived: the surviving
// query's server remark has to reach the caller both at the top level and in
// its own sidecar entry.
if envelope.Data.Notice != wantNotice {
t.Errorf("top-level notice: got %q, want %q", envelope.Data.Notice, wantNotice)
}
if len(envelope.Data.Queries) != 2 {
t.Fatalf("both queries must be enumerated: %+v", envelope.Data.Queries)
}
if envelope.Data.Queries[0].Notice != wantNotice {
t.Errorf("surviving query notice: got %q, want %q", envelope.Data.Queries[0].Notice, wantNotice)
}
if envelope.Data.Queries[0].Error != "" {
t.Errorf("surviving query must carry no error: %q", envelope.Data.Queries[0].Error)
}
if !strings.Contains(envelope.Data.Queries[1].Error, "500") {
t.Errorf("failed query must carry the upstream status: %q", envelope.Data.Queries[1].Error)
}
// Only the surviving query contributes rows.
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].MatchedQuery != "会议" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
}
func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String())
}
// csv is in the summary format set, so the batch counters belong on stderr.
if !strings.Contains(stderr.String(), "2 queries, 2 total matches") || !strings.Contains(stderr.String(), "0 failed") {
t.Errorf("stderr summary must report the batch counters: %s", stderr.String())
}
if strings.Contains(stderr.String(), "total bots") {
t.Errorf("summary must count matches rather than imply unique bots: %s", stderr.String())
}
}
func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL, "")
stub.Reusable = true
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", "ndjson", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
// ndjson is a machine format outside the summary set: every stdout line must
// parse, and the counters must not be mixed in.
for i, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
if line == "" {
continue
}
var row map[string]interface{}
if err := json.Unmarshal([]byte(line), &row); err != nil {
t.Fatalf("stdout line %d is not JSON: %q", i, line)
}
}
if strings.Contains(stderr.String(), "queries,") {
t.Errorf("ndjson must not emit the summary line: %s", stderr.String())
}
}
// TestBotFanoutCancelledSchedulingFailsQueuedQueries drives the real command so
// the scheduler inside executeBotSearchFanout — not just runOneBotQuery — sees
// the cancellation. Queueing more keywords than fanoutConcurrency while every
// worker is parked keeps all semaphore slots held, so the queued keywords can
// only leave the loop through its ctx.Done() branch.
func TestBotFanoutCancelledSchedulingFailsQueuedQueries(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
var once sync.Once
stub := botSearchStub(botSearchURL+"?page_size=20", "")
stub.Reusable = true
stub.OnMatch = func(*http.Request) {
once.Do(func() { close(started) })
<-ctx.Done() // hold the slot so later keywords must queue on the semaphore
}
registry.Register(stub)
go func() {
select {
case <-started:
case <-time.After(5 * time.Second): // never leave the workers parked
}
cancel()
}()
queries := make([]string, 0, fanoutConcurrency+3)
for i := 0; i < fanoutConcurrency+3; i++ {
queries = append(queries, fmt.Sprintf("q%d", i))
}
err := mountAndRunContext(t, ctx, ContactSearchBot, []string{
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("a cancelled batch must surface as a command error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
// TestBotFanoutCancelledContextShortCircuitsBeforeRequest pins the other half:
// a queued worker must fail on the pre-check instead of issuing its request.
func TestBotFanoutCancelledContextShortCircuitsBeforeRequest(t *testing.T) {
results := make([]botFanoutResult, 0, 2)
ctx, cancel := context.WithCancel(context.Background())
cancel()
for i, q := range []string{"会议", "日报"} {
results = append(results, runOneBotQuery(ctx, nil, i, q, nil))
}
for _, r := range results {
if r.ErrMsg == "" {
t.Fatalf("a cancelled context must short-circuit before the request: %+v", r)
}
}
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
if err == nil {
t.Fatal("all queries cancelled must surface as an error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation cause must be preserved: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
}
}
func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "queries", "会议, 日报 ,会议")
setBotSearchFlag(t, cmd, "chat-ids", "oc_a")
setBotSearchFlag(t, cmd, "has-chatted", "true")
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
raw, err := json.Marshal(ContactSearchBot.DryRun(context.Background(), runtime))
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
var preview struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body struct {
Query string `json:"query"`
Filter *struct {
ChatIDs []string `json:"chat_ids"`
HasChatter bool `json:"has_chatter"`
} `json:"filter"`
} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(raw, &preview); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, raw)
}
// Deduped, so the repeated keyword previews once — the preview has to match
// the requests Execute would actually issue.
if len(preview.API) != 2 {
t.Fatalf("expected one previewed request per deduped keyword, got %d: %s", len(preview.API), raw)
}
seen := make([]string, 0, len(preview.API))
for i, call := range preview.API {
if call.Method != "POST" || call.URL != botSearchURL {
t.Errorf("api[%d]: got %s %s", i, call.Method, call.URL)
}
if call.Params["page_size"] != float64(20) {
t.Errorf("api[%d] page_size: %v", i, call.Params["page_size"])
}
if _, ok := call.Params["page_token"]; ok {
t.Errorf("api[%d] must not preview a page_token: %v", i, call.Params)
}
// The filter rides along with every keyword, not just the first.
if call.Body.Filter == nil || !call.Body.Filter.HasChatter ||
len(call.Body.Filter.ChatIDs) != 1 || call.Body.Filter.ChatIDs[0] != "oc_a" {
t.Errorf("api[%d] filter: %+v", i, call.Body.Filter)
}
seen = append(seen, call.Body.Query)
}
if fmt.Sprint(seen) != fmt.Sprint([]string{"会议", "日报"}) {
t.Errorf("previewed keywords: got %v, want [会议 日报]", seen)
}
}
// The summary counts how many queries failed but never says which or why, and
// only json carries queries[].error. Without a per-query line on stderr an agent
// reading csv sees "1 failed" and cannot recover the keyword or the reason.
func TestBotFanoutFailedQueryIsNamedOnStderr(t *testing.T) {
for _, format := range []string{"csv", "table", "pretty", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
broken := botSearchStub(botSearchURL, "")
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
broken.Status = 500
broken.Body = map[string]interface{}{"reason": "boom"}
registry.Register(broken)
okStub := botSearchStub(botSearchURL, "")
okStub.Reusable = true
registry.Register(okStub)
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--queries", "会议,日报", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("one failing query must not fail the batch: %v", err)
}
for _, want := range []string{"日报", "500"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s: stderr must name the failed query and its reason (missing %q)\nstderr:\n%s",
format, want, stderr.String())
}
}
})
}
}

View File

@@ -0,0 +1,724 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newBotSearchTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("chat-ids", "", "")
cmd.Flags().Bool("has-chatted", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("queries", "", "")
return cmd
}
func botSearchDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
UserOpenId: "ou_self",
}
}
func setBotSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) {
t.Helper()
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%q: %v", name, value, err)
}
}
func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: got %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if validationErr.Param != wantParam {
t.Fatalf("param: got %q, want %q", validationErr.Param, wantParam)
}
}
// assertBotSearchValidationParams covers the errors that name several flags via
// WithParams; those leave the single Param empty on purpose, so an agent reading
// the envelope sees every flag that could satisfy the requirement.
func assertBotSearchValidationParams(t *testing.T, err error, wantParams []string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem: %+v ok=%v", problem, ok)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
got := make([]string, 0, len(validationErr.Params))
for _, p := range validationErr.Params {
if p.Reason == "" {
t.Errorf("param %q has no reason; agents read it to pick a recovery", p.Name)
}
got = append(got, p.Name)
}
if fmt.Sprint(got) != fmt.Sprint(wantParams) {
t.Fatalf("params: got %v, want %v", got, wantParams)
}
}
func TestValidateBotSearchErrors(t *testing.T) {
chatIDs := make([]string, 101)
for i := range chatIDs {
chatIDs[i] = fmt.Sprintf("oc_%03d", i)
}
tests := []struct {
name string
flags map[string]string
wantParam string
wantParams []string // set instead of wantParam when the error names several flags
wantMessage string
}{
{
name: "keyword missing",
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "query over 50 characters",
flags: map[string]string{"query": strings.Repeat("中", 51)},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
name: "chat ids parse empty",
flags: map[string]string{"query": "x", "chat-ids": " , , "},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: no valid chat_id parsed from \", ,\" (separate entries with ',')",
},
{
name: "over 100 chat ids",
flags: map[string]string{"query": "x", "chat-ids": strings.Join(chatIDs, ",")},
wantParam: "--chat-ids",
wantMessage: "--chat-ids: must be at most 100 entries",
},
{
name: "invalid chat id",
flags: map[string]string{"query": "x", "chat-ids": "bad"},
wantParam: "--chat-ids",
wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)",
},
{
// With a keyword present the keyword errors win, exactly as +search-user
// orders them; the =false check must not be hoisted above these.
name: "mutually exclusive keywords outrank has chatted false",
flags: map[string]string{"query": "x", "queries": "y", "has-chatted": "false"},
wantParams: []string{"--query", "--queries"},
wantMessage: "--query and --queries are mutually exclusive",
},
{
name: "query length outranks has chatted false",
flags: map[string]string{"query": strings.Repeat("中", 51), "has-chatted": "false"},
wantParam: "--query",
wantMessage: "--query: length must be between 1 and 50 characters",
},
{
// With no keyword at all the explicit =false is the more specific mistake,
// so it wins over the missing-keyword error rather than costing a second
// round trip. Matches which error +search-user reports first.
name: "has chatted false without a keyword",
flags: map[string]string{"has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "has chatted false",
flags: map[string]string{"query": "x", "has-chatted": "false"},
wantParam: "--has-chatted",
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
},
{
name: "page size below one",
flags: map[string]string{"query": "x", "page-size": "0"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "page size over 30",
flags: map[string]string{"query": "x", "page-size": "31"},
wantParam: "--page-size",
wantMessage: "--page-size: must be between 1 and 30",
},
{
name: "chat ids without a keyword",
flags: map[string]string{"chat-ids": "oc_a"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
{
name: "has chatted without a keyword",
flags: map[string]string{"has-chatted": "true"},
wantParams: []string{"--query", "--queries"},
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if len(tt.wantParams) > 0 {
assertBotSearchValidationParams(t, err, tt.wantParams)
} else {
assertBotSearchValidationProblem(t, err, tt.wantParam)
}
if err.Error() != tt.wantMessage {
t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestValidateBotSearchPassingCases(t *testing.T) {
tests := []struct {
name string
flags map[string]string
}{
{name: "query only", flags: map[string]string{"query": "x"}},
{name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}},
{name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}},
{name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}},
{name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}},
// An explicitly blank string flag reads as "no filter", matching how
// +search-user treats --user-ids / --queries. Only a non-blank value that
// parses to zero entries is an error.
{name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}},
{name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}},
// Duplicates collapse before the cap is checked, so 101 copies of one chat
// is one entry — matching how --user-ids is resolved for +search-user.
{name: "duplicate chat ids collapse under the cap", flags: map[string]string{
"query": "x", "chat-ids": strings.TrimSuffix(strings.Repeat("oc_a,", 101), ","),
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
if err := validateBotSearch(runtime); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateBotSearchQueryRuneBoundary(t *testing.T) {
for _, tt := range []struct {
name string
query string
wantError bool
}{
{name: "50 CJK characters", query: strings.Repeat("中", 50)},
{name: "51 CJK characters", query: strings.Repeat("中", 51), wantError: true},
} {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
setBotSearchFlag(t, cmd, "query", tt.query)
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
err := validateBotSearch(runtime)
if tt.wantError {
assertBotSearchValidationProblem(t, err, "--query")
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestBuildBotSearchBody(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantJSON string
}{
{name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`},
{name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`},
{name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`},
// A blank --chat-ids must not materialize an empty filter object.
{name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`},
// Deduped after normalization, so a repeated id and a URL naming the same
// chat both collapse into one entry instead of burning the server's quota.
{name: "duplicate chat ids deduped", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
{name: "URL and bare id dedupe to one", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_a"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a"]}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newBotSearchTestCommand()
for name, value := range tt.flags {
setBotSearchFlag(t, cmd, name, value)
}
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
body, err := buildBotSearchBody(runtime)
if err != nil {
t.Fatalf("build body: %v", err)
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
if string(raw) != tt.wantJSON {
t.Fatalf("body: got %s, want %s", raw, tt.wantJSON)
}
})
}
}
func TestParseBotDisplayInfo(t *testing.T) {
tests := []struct {
name string
raw string
wantName string
wantDescription string
wantSegments []string
}{
// Whole name highlighted, description on line two.
{name: "whole name highlighted", raw: "<h>甲乙丙</h>\n一句话简介", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}},
// Two highlighted runs split by a plain character: stripping tags has to
// rejoin them into one name.
{name: "two highlighted runs", raw: "<h>甲乙</h>丁<h>丙</h>\n另一句简介", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}},
// Highlight at the end plus a trailing newline: line two exists but is empty.
{name: "trailing newline empty description", raw: "戊己的<h>庚辛</h>\n", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}},
// Single highlighted character in the middle of the name.
{name: "mid-name highlight", raw: "壬癸<h>子</h>丑\n第二行简介", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}},
{name: "no newline", raw: "寅卯", wantName: "寅卯", wantSegments: []string{}},
{name: "html entities", raw: "<h>Lark</h>部门成员&amp;仓库\n来自飞书&#22810;维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
{name: "html entity in highlight", raw: "名称<h>&amp;</h>工具", wantName: "名称&工具", wantSegments: []string{"&"}},
{name: "empty", raw: "", wantSegments: []string{}},
{name: "first non-empty line", raw: "\n\n真名", wantName: "真名", wantSegments: []string{}},
// A blank first line must not make the description echo the name back and
// swallow the real description on the line after it.
{name: "blank first line keeps description", raw: "\n真名\n简介", wantName: "真名", wantDescription: "简介", wantSegments: []string{}},
{name: "blank first line without description", raw: "\n真名", wantName: "真名", wantSegments: []string{}},
// A highlight with no text carries nothing; an empty match segment is junk
// in the envelope. Which line the name comes from is left unchanged.
{name: "empty highlight yields no segment", raw: "<h></h>\n简介", wantName: "简介", wantSegments: []string{}},
// The non-greedy pattern pairs a stray `<h>` with the next `</h>`, so the
// capture can carry a tag the name and description already dropped.
{name: "nested highlight", raw: "<h>甲<h>乙</h></h>\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{"甲乙"}},
{name: "dangling open tag", raw: "<h><h>甲</h>\n简介", wantName: "甲", wantDescription: "简介", wantSegments: []string{"甲"}},
{name: "unclosed highlight", raw: "<h>甲乙\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{}},
// A literal `<h>` in a name arrives escaped, so it must survive: tags are
// stripped before unescaping. Swapping that order eats the name's own text.
{name: "escaped angle brackets are name text", raw: "名称&lt;h&gt;工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
{name: "escaped angle brackets inside a highlight", raw: "<h>名称&lt;h&gt;</h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{"名称<h>"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, description, segments := parseBotDisplayInfo(tt.raw)
if name != tt.wantName || description != tt.wantDescription {
t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription)
}
if segments == nil {
t.Fatal("match segments must be an empty slice, not nil")
}
if fmt.Sprint(segments) != fmt.Sprint(tt.wantSegments) {
t.Fatalf("match segments: got %v, want %v", segments, tt.wantSegments)
}
})
}
}
func TestProjectBotsMapsEveryField(t *testing.T) {
data := &botSearchAPIData{Items: []botSearchAPIItem{
{
ID: "ou_with_chat",
DisplayInfo: "<h>甲乙丙</h>\n一句话简介",
MetaData: botSearchAPIMeta{
TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true,
},
},
{
ID: "ou_without_chat",
DisplayInfo: "",
MetaData: botSearchAPIMeta{TenantID: "1"},
},
}}
bots := projectBots(data)
if len(bots) != 2 {
t.Fatalf("bots: got %d, want 2", len(bots))
}
first := bots[0]
if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" ||
first.ChatID != "oc_p2p" || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" ||
fmt.Sprint(first.MatchSegments) != "[甲乙丙]" {
t.Fatalf("first bot mapping: %+v", first)
}
second := bots[1]
if second.Name != "" || second.ChatID != "" {
t.Fatalf("empty source fields must stay empty: %+v", second)
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if !strings.Contains(string(raw), `"chat_id":""`) {
t.Fatalf("empty chat_id must still be emitted: %s", raw)
}
if !strings.Contains(string(raw), `"name":""`) {
t.Fatalf("empty name must not fall back to open_id: %s", raw)
}
if strings.Contains(string(raw), `"has_chatted"`) {
t.Fatalf("chat_id presence must not be exposed as a has_chatted signal: %s", raw)
}
}
func TestProjectBotsEmptySerializesAsArray(t *testing.T) {
bots := projectBots(&botSearchAPIData{Items: []botSearchAPIItem{}})
if bots == nil {
t.Fatal("bots must be an empty slice, not nil")
}
raw, err := json.Marshal(searchBotResponse{Bots: bots})
if err != nil {
t.Fatalf("marshal response: %v", err)
}
if string(raw) != `{"bots":[],"has_more":false}` {
t.Fatalf("response: got %s", raw)
}
}
func botSearchStub(url string, pageToken string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"has_more": true,
"page_token": pageToken,
"items": []interface{}{
map[string]interface{}{
"id": "ou_bot",
"display_info": "<h>甲乙丙</h>\n一句话简介",
"meta_data": map[string]interface{}{
"tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false,
},
},
},
},
},
}
}
func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
stub := botSearchStub(botSearchURL+"?page_size=25", "cursor_out")
registry.Register(stub)
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted",
"--page-size", "25", "--format", "json", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
t.Fatalf("request body: %v", err)
}
if requestBody["query"] != "甲乙" {
t.Fatalf("request query: got %v", requestBody["query"])
}
filter, ok := requestBody["filter"].(map[string]interface{})
if !ok || filter["has_chatter"] != true || fmt.Sprint(filter["chat_ids"]) != "[oc_a oc_b]" {
t.Fatalf("request filter: %#v", requestBody["filter"])
}
var envelope struct {
Data searchBotResponse `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
}
if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore {
t.Fatalf("response pass-through: %+v", envelope.Data)
}
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].ChatID != "oc_p2p" {
t.Fatalf("bots: %+v", envelope.Data.Bots)
}
registry.Verify(t)
}
func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
// The stub returns a token; the envelope must still not carry one, matching
// +search-user, which decodes page_token and drops it.
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "json", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("response JSON: %v", err)
}
data := envelope["data"].(map[string]interface{})
if _, ok := data["page_token"]; ok {
t.Fatalf("page_token must never be surfaced: %v", data)
}
}
func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, column := range []string{"name", "description", "is_agent", "enable_join_group", "open_id"} {
if !strings.Contains(stdout.String(), column) {
t.Errorf("pretty output missing %q: %s", column, stdout.String())
}
}
for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "chat_id", "match_segments"} {
if strings.Contains(stdout.String(), genericField) {
t.Errorf("pretty output exposed %q: %s", genericField, stdout.String())
}
}
// pretty stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("pretty stderr missing %q: %q", want, stderr.String())
}
}
}
func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "table", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("table output missing %q: %s", field, stdout.String())
}
}
// table stdout carries rows only, so stderr has to carry both the server
// notice and the pagination hint.
for _, want := range []string{
"notice: The query is too long and has been truncated to the first 50 characters for search.",
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("table stderr missing %q: %q", want, stderr.String())
}
}
}
// The old name and assertion here pinned a bug: csv and ndjson were the two
// formats that carried neither has_more in stdout nor a hint on stderr, so a
// machine caller read a truncated result as the whole answer. stdout stays
// data-only; the truncation signal belongs on stderr for every format whose
// stdout has no envelope.
func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) {
for _, format := range []string{"csv", "ndjson"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", format, "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
if !strings.Contains(stdout.String(), field) {
t.Errorf("%s output missing %q: %s", format, field, stdout.String())
}
}
// stdout must stay data-only, so both the notice and the truncation
// signal have to arrive on stderr.
for _, want := range []string{"notice: The query is too long", "hint: more matches exist"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("%s dropped %q from stderr: %q", format, want, stderr.String())
}
}
if strings.Contains(stdout.String(), "more matches exist") {
t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String())
}
})
}
}
func TestBotSearchPrettyEmptyResult(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: botSearchURL + "?page_size=20",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "none", "--format", "pretty", "--as", "user"}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "No bots found.") {
t.Fatalf("pretty output: %q", stdout.String())
}
}
func TestBotSearchDryRunMirrorsRequest(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig())
err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a", "--has-chatted",
"--page-size", "25", "--dry-run", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envelope struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body botSearchAPIRequest `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("dry-run JSON: %v", err)
}
if len(envelope.Data.API) != 1 {
t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API))
}
call := envelope.Data.API[0]
if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) {
t.Fatalf("dry-run call: %+v", call)
}
if call.Body.Query != "甲乙" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter {
t.Fatalf("dry-run body: %+v", call.Body)
}
}
func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) {
_, err := decodeBotSearchAPIData(map[string]interface{}{"bad": func() {}})
if err == nil {
t.Fatal("expected marshal failure")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem: %+v, ok=%v", problem, ok)
}
}
// Only the json envelope carries data.notice. If the other formats dropped it
// silently, a caller would read a truncated or incomplete result as a complete
// one, so every non-json format has to surface it on stderr instead.
func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", ""))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stdout.String(), notice) {
if format != "json" {
t.Fatalf("%s should not carry the notice in stdout: %s", format, stdout.String())
}
return
}
if !strings.Contains(stderr.String(), notice) {
t.Fatalf("%s dropped the notice entirely\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
// stdout stays pipe-clean: the notice must not be mixed into the rows.
if format == "csv" && strings.Contains(stdout.String(), "notice") {
t.Fatalf("csv stdout must stay data-only: %s", stdout.String())
}
})
}
}
// has_more is the server saying "this is not the whole answer". Only the json
// envelope carries it, so every other format has to say so on stderr or a machine
// caller silently treats a truncated result as complete.
func TestBotSearchTruncationReachesCallerInEveryFormat(t *testing.T) {
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
t.Run(format, func(t *testing.T) {
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor"))
if err := mountAndRun(t, ContactSearchBot, []string{
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
if format == "json" {
if !strings.Contains(stdout.String(), `"has_more": true`) {
t.Fatalf("json must carry has_more in the envelope: %s", stdout.String())
}
return
}
if !strings.Contains(stderr.String(), "more matches exist") {
t.Fatalf("%s left the caller unable to learn the result was truncated\nstdout:\n%s\nstderr:\n%s",
format, stdout.String(), stderr.String())
}
})
}
}

View File

@@ -550,6 +550,13 @@ func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
// mountAndRun mounts the shortcut under a parent cobra command and runs it
// with the given args. Mirrors the pattern used in other shortcut packages.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
return mountAndRunContext(t, context.Background(), s, args, f, stdout)
}
// mountAndRunContext is mountAndRun with a caller-supplied context, so a test
// can cancel the run the shortcut actually sees (runShortcut reads cmd.Context).
func mountAndRunContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "contact"}
s.Mount(parent, f)
@@ -559,7 +566,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
return parent.ExecuteContext(ctx)
}
// searchUserStub returns a representative user search response with a notice.

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ContactSearchUser,
ContactSearchBot,
ContactGetUser,
}
}

View File

@@ -47,6 +47,14 @@ const defaultLocateDocLimit = 10
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
// maxCommentReplyElements is the element-count cap declared ONLY by the
// reply-create endpoint (POST .../comments/:comment_id/replies), whose
// content.elements schema says "最大元素个数为100". It is enforced only by
// +add-reply. create_v2 (+add-comment) and the reply-update endpoint
// (+update-reply) do not declare this cap, so their inputs are not capped
// here — see the shared parseCommentReplyElements, which stays uncapped.
const maxCommentReplyElements = 100
// The file comment API treats supported Drive file comments as full-file
// comments in the UI, but currently rejects an empty anchor.block_id for file
// targets. TODO: remove this placeholder after the API accepts omitting

View File

@@ -918,6 +918,27 @@ func TestSheetCommentValidateInvalidBlockIDFormat(t *testing.T) {
}
}
// create_v2 (+add-comment) uses reply_elements, which does NOT declare the
// 100-element cap that the reply-create endpoint does; +add-comment must not
// reject >100 elements locally.
func TestDriveAddCommentDoesNotCapElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/docx/docxToken",
"--content", "[" + strings.Join(elems, ",") + "]",
"--full-comment",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("+add-comment must not cap element count locally, got %v", err)
}
}
func TestSheetCommentValidateRejectsFullComment(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveAddReplyOp = driveCommentOp{
Label: "comment reply",
Types: []string{"doc", "docx", "sheet", "file", "slides", "bitable", "apps"},
}
type driveAddReplySpec struct {
Ref driveCommentRef
CommentID string
ReplyElements []map[string]interface{} // simplified +add-comment element form, text already escaped
}
func (s driveAddReplySpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"content": map[string]interface{}{
"elements": driveReplyV1Elements(s.ReplyElements),
},
}
}
// DriveAddReply replies to an existing comment through the Drive comment
// reply create API (POST .../comments/:comment_id/replies), while accepting
// Wiki URLs/tokens and resolving them to the underlying object.
//
// Note: the documented alternative — POST .../comments with comment_id in the
// body ("如填写,则视为回复已有评论") — does NOT reply on docx in practice; it
// silently creates a new standalone comment instead.
var DriveAddReply = common.Shortcut{
Service: "drive",
Command: "+add-reply",
Description: "Add a reply to an existing comment on doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "write",
Scopes: []string{"docs:document.comment:create"},
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: append(driveCommentTargetFlags(driveAddReplyOp),
common.Flag{Name: "comment-id", Desc: "comment ID to reply to (from drive +list-comments)", Required: true},
common.Flag{Name: "content", Desc: "reply_elements JSON string, same format as drive +add-comment", Required: true, Input: []string{common.File, common.Stdin}},
),
Tips: []string{
"--content uses the same JSON as `drive +add-comment`: '[{\"type\":\"text\",\"text\":\"正文\"}]' (types: text, mention_user, link).",
"Comment IDs come from `drive +list-comments` (items[].comment_id).",
"Whole-document comments (is_whole=true) and solved comments (is_solved=true) do not accept replies; check the comment state via `drive +list-comments` first.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveAddReplySpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveAddReplySpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveAddReplyDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveAddReplySpec(runtime)
if err != nil {
return err
}
target, err := resolveDriveCommentTarget(ctx, runtime, driveAddReplyOp, spec.Ref)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Adding reply to comment %s in %s...\n", spec.CommentID, common.MaskToken(target.FileToken))
path := fmt.Sprintf(
"/open-apis/drive/v1/files/%s/comments/%s/replies",
validate.EncodePathSegment(target.FileToken),
validate.EncodePathSegment(spec.CommentID),
)
data, err := runtime.CallAPITyped(
"POST",
path,
map[string]interface{}{"file_type": target.FileType},
spec.RequestBody(),
)
if err != nil {
return err
}
extra := map[string]interface{}{
"comment_id": spec.CommentID,
"created": true,
}
if replyID := extractDriveCreatedReplyID(data); replyID != "" {
extra["reply_id"] = replyID
}
runtime.Out(driveCommentTargetOutput(target, extra), nil)
return nil
},
}
func readDriveAddReplySpec(runtime *common.RuntimeContext) (driveAddReplySpec, error) {
ref, err := resolveDriveCommentInput(driveAddReplyOp, runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveAddReplySpec{}, err
}
commentID := strings.TrimSpace(runtime.Str("comment-id"))
if err := validateDriveCommentPathID(commentID, "--comment-id"); err != nil {
return driveAddReplySpec{}, err
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return driveAddReplySpec{}, err
}
// The reply-create endpoint documents a 100-element cap on content.elements;
// reject over-cap input locally instead of surfacing the opaque [1069302].
if len(replyElements) > maxCommentReplyElements {
return driveAddReplySpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--content has %d elements; the reply endpoint caps content.elements at %d", len(replyElements), maxCommentReplyElements).
WithParam("--content")
}
return driveAddReplySpec{
Ref: ref,
CommentID: commentID,
ReplyElements: replyElements,
}, nil
}
// driveReplyV1Elements converts the simplified +add-comment reply element form
// (text / mention_user / link) to the Drive v1 comment create wire form
// (text_run / person / docs_link).
func driveReplyV1Elements(replyElements []map[string]interface{}) []map[string]interface{} {
elements := make([]map[string]interface{}, 0, len(replyElements))
for _, element := range replyElements {
switch common.GetString(element, "type") {
case "text":
elements = append(elements, map[string]interface{}{
"type": "text_run",
"text_run": map[string]interface{}{"text": common.GetString(element, "text")},
})
case "mention_user":
elements = append(elements, map[string]interface{}{
"type": "person",
"person": map[string]interface{}{"user_id": common.GetString(element, "mention_user")},
})
case "link":
elements = append(elements, map[string]interface{}{
"type": "docs_link",
"docs_link": map[string]interface{}{"url": common.GetString(element, "link")},
})
}
}
return elements
}
// extractDriveCreatedReplyID pulls the created reply ID out of the reply
// create response, tolerating the shapes the API family uses: a top-level
// reply_id, a nested reply object, or a reply_list wrapper.
func extractDriveCreatedReplyID(data map[string]interface{}) string {
if replyID := common.GetString(data, "reply_id"); replyID != "" {
return replyID
}
if reply := common.GetMap(data, "reply"); reply != nil {
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
return replyID
}
}
replyList := common.GetMap(data, "reply_list")
if replyList == nil {
return ""
}
for _, item := range common.GetSlice(replyList, "replies") {
reply, ok := item.(map[string]interface{})
if !ok {
continue
}
if replyID := common.GetString(reply, "reply_id"); replyID != "" {
return replyID
}
}
return ""
}
func buildDriveAddReplyDryRun(spec driveAddReplySpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> add reply to comment").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
POST("/open-apis/drive/v1/files/<obj_token from step 1>/comments/:comment_id/replies").
Desc("[2] Add reply to comment on resolved document").
Params(map[string]interface{}{"file_type": "<obj_type from step 1>"}).
Body(spec.RequestBody()).
Set("comment_id", spec.CommentID)
}
return common.NewDryRunAPI().
Desc("1-step request: add reply to comment").
POST("/open-apis/drive/v1/files/:file_token/comments/:comment_id/replies").
Params(map[string]interface{}{"file_type": spec.Ref.Type}).
Body(spec.RequestBody()).
Set("file_token", spec.Ref.Token).
Set("comment_id", spec.CommentID)
}

View File

@@ -0,0 +1,393 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveReplyV1Elements(t *testing.T) {
t.Parallel()
elements, err := parseCommentReplyElements(`[
{"type":"text","text":"a<b"},
{"type":"mention_user","mention_user":"ou_123"},
{"type":"link","link":"https://example.com"}
]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := driveReplyV1Elements(elements)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
if got[0]["type"] != "text_run" {
t.Fatalf("elements[0].type = %#v, want text_run", got[0]["type"])
}
textRun, ok := got[0]["text_run"].(map[string]interface{})
if !ok {
t.Fatalf("elements[0].text_run is %T, want map", got[0]["text_run"])
}
if textRun["text"] != "a&lt;b" {
t.Fatalf("elements[0].text_run.text = %#v, want escaped a&lt;b", textRun["text"])
}
person, ok := got[1]["person"].(map[string]interface{})
if !ok || got[1]["type"] != "person" {
t.Fatalf("elements[1] = %#v, want person element", got[1])
}
if person["user_id"] != "ou_123" {
t.Fatalf("elements[1].person.user_id = %#v, want ou_123", person["user_id"])
}
docsLink, ok := got[2]["docs_link"].(map[string]interface{})
if !ok || got[2]["type"] != "docs_link" {
t.Fatalf("elements[2] = %#v, want docs_link element", got[2])
}
if docsLink["url"] != "https://example.com" {
t.Fatalf("elements[2].docs_link.url = %#v, want https://example.com", docsLink["url"])
}
}
func TestDriveAddReplyExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"reply": map[string]interface{}{
"reply_id": "reply_9",
},
},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"收到,我来处理"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("failed to decode captured request body: %v", err)
}
if _, ok := body["comment_id"]; ok {
t.Fatalf("request body must not carry comment_id (it rides in the URL path): %v", body)
}
content := mustMapValue(t, body["content"], "request.content")
elements := mustSliceValue(t, content["elements"], "request.content.elements")
element := mustMapValue(t, elements[0], "request.content.elements[0]")
if got := mustStringField(t, element, "type", "request.content.elements[0].type"); got != "text_run" {
t.Fatalf("request element type = %q, want text_run", got)
}
elementText := mustMapValue(t, element["text_run"], "request.content.elements[0].text_run")
if got := mustStringField(t, elementText, "text", "request.content.elements[0].text_run.text"); got != "收到,我来处理" {
t.Fatalf("text_run.text = %q, want 收到,我来处理", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "comment_id", "data.comment_id"); got != "comment_1" {
t.Fatalf("comment_id = %q, want comment_1", got)
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "reply_9" {
t.Fatalf("reply_id = %q, want reply_9", got)
}
if got := data["created"]; got != true {
t.Fatalf("created = %#v, want true", got)
}
}
func TestDriveAddReplyExecuteWikiResolvesToDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWiki",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxFromWiki/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply from wiki"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWiki" {
t.Fatalf("file_token = %q, want docxFromWiki", got)
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiResource" {
t.Fatalf("wiki_token = %q, want wikiResource", got)
}
if _, ok := data["reply_id"]; ok {
t.Fatalf("reply_id should be omitted when the response carries none: %v", data)
}
}
func TestDriveAddReplyRejectsUnsupportedTargets(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/drive/folder/folderResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `unsupported --url resource type "folder"`) {
t.Fatalf("expected unsupported-type error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestDriveAddReplyWikiResolvesToUnsupported(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "mindnote",
"obj_token": "mindnoteFromWiki",
},
},
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), `wiki resolved to "mindnote", but comment reply only supports`) {
t.Fatalf("expected wiki-resolution error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--url")
}
func TestExtractDriveCreatedReplyID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
want string
}{
{name: "nil data", data: nil, want: ""},
{name: "top-level reply_id", data: map[string]interface{}{"reply_id": "r1"}, want: "r1"},
{name: "nested reply object", data: map[string]interface{}{"reply": map[string]interface{}{"reply_id": "r2"}}, want: "r2"},
{name: "nested reply without id falls through", data: map[string]interface{}{"reply": map[string]interface{}{}}, want: ""},
{
name: "reply_list wrapper",
data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{
"not-a-map",
map[string]interface{}{"reply_id": ""},
map[string]interface{}{"reply_id": "r3"},
}}},
want: "r3",
},
{name: "reply_list without match", data: map[string]interface{}{"reply_list": map[string]interface{}{"replies": []interface{}{map[string]interface{}{}}}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := extractDriveCreatedReplyID(tt.data); got != tt.want {
t.Fatalf("extractDriveCreatedReplyID() = %q, want %q", got, tt.want)
}
})
}
}
func TestDriveAddReplyRejectsUnsafeCommentID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "../admin",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("expected comment-id validation error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--comment-id")
}
func TestDriveAddReplyPropagatesAPIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/docxResource/comments/comment_1/replies",
Body: map[string]interface{}{
"code": 1069307,
"msg": "comment not found",
},
})
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "comment not found") {
t.Fatalf("expected API error to propagate, got %v", err)
}
}
func TestDriveAddReplyDryRunWiki(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/wiki/wikiResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("dry-run api call count = %d, want 2\nstdout:\n%s", len(api), stdout.String())
}
step2 := mustMapValue(t, api[1], "api[1]")
if got := mustStringField(t, step2, "url", "api[1].url"); !strings.Contains(got, "/comments/comment_1/replies") {
t.Fatalf("api[1].url = %q, want replies URL with comment ID", got)
}
}
func TestDriveAddReplyInvalidContent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `not-json`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") {
t.Fatalf("expected content JSON error, got %v", err)
}
}
func TestDriveAddReplyRejectsTooManyElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 101)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", "[" + strings.Join(elems, ",") + "]",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "caps content.elements at 100") {
t.Fatalf("expected 100-element cap error, got %v", err)
}
assertDriveCommentValidationError(t, err, "--content")
}
func TestDriveAddReplyAcceptsMaxElements(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
elems := make([]string, 100)
for i := range elems {
elems[i] = `{"type":"text","text":"x"}`
}
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", "[" + strings.Join(elems, ",") + "]",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("100 elements should be accepted, got %v", err)
}
}
func TestDriveAddReplyDryRunDirect(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddReply, []string{
"+add-reply",
"--url", "https://example.larksuite.com/docx/docxResource",
"--comment-id", "comment_1",
"--content", `[{"type":"text","text":"reply"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 1 {
t.Fatalf("dry-run api call count = %d, want 1\nstdout:\n%s", len(api), stdout.String())
}
call := mustMapValue(t, api[0], "api[0]")
if got := mustStringField(t, call, "url", "api[0].url"); !strings.Contains(got, "/files/docxResource/comments/comment_1/replies") {
t.Fatalf("api[0].url = %q, want reply create URL with comment ID", got)
}
body := mustMapValue(t, call["body"], "api[0].body")
if _, ok := body["comment_id"]; ok {
t.Fatalf("api[0].body must not carry comment_id: %v", body)
}
content := mustMapValue(t, body["content"], "api[0].body.content")
if _, ok := content["elements"]; !ok {
t.Fatalf("api[0].body.content.elements missing: %v", body)
}
}

View File

@@ -6,6 +6,7 @@ package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -13,72 +14,137 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// permApplyTypes is the authoritative list of type values the apply-permission
// endpoint accepts for its required `type` query parameter.
var permApplyTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "slides",
type permApplyResourceKind struct {
Type string
Path string
}
// permApplyURLMarkers maps document URL path markers to the `type` value the
// apply-permission endpoint expects. Markers are disjoint strings (each begins
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
var permApplyURLMarkers = []struct {
Marker string
Type string
}{
{"/wiki/", "wiki"},
{"/docx/", "docx"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/file/", "file"},
{"/mindnote/", "mindnote"},
{"/slides/", "slides"},
{"/doc/", "doc"},
// permApplyResourceKinds is the authoritative target contract for the
// apply-permission endpoint: accepted types and their URL root paths.
var permApplyResourceKinds = []permApplyResourceKind{
{Type: "doc", Path: "/doc/"},
{Type: "sheet", Path: "/sheets/"},
{Type: "file", Path: "/file/"},
{Type: "wiki", Path: "/wiki/"},
{Type: "bitable", Path: "/base/"},
{Type: "bitable", Path: "/bitable/"},
{Type: "docx", Path: "/docx/"},
{Type: "mindnote", Path: "/mindnote/"},
{Type: "slides", Path: "/slides/"},
{Type: "apps", Path: "/page/"},
}
var permApplyTypes = func() []string {
types := make([]string, 0, len(permApplyResourceKinds))
seen := make(map[string]struct{}, len(permApplyResourceKinds))
for _, resourceKind := range permApplyResourceKinds {
if _, ok := seen[resourceKind.Type]; ok {
continue
}
seen[resourceKind.Type] = struct{}{}
types = append(types, resourceKind.Type)
}
return types
}()
func permApplyTypeAllowed(docType string) bool {
for _, allowedType := range permApplyTypes {
if docType == allowedType {
return true
}
}
return false
}
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
// value that may be either a bare token or a full document URL, plus an
// optional explicit --type. Explicit --type wins over URL inference.
// optional explicit --type. A URL's path and explicit --type must agree.
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
raw = strings.TrimSpace(raw)
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if explicitType != "" && !permApplyTypeAllowed(explicitType) {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid --type %q: allowed values are %s",
explicitType,
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
if strings.Contains(raw, "://") {
for _, m := range permApplyURLMarkers {
if tok, ok := extractURLToken(raw, m.Marker); ok {
token = tok
if explicitType == "" {
docType = m.Type
}
break
}
}
if token == "" {
ref, ok := parsePermApplyResourceURL(raw)
if !ok {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/, /page/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
).WithParam("--token")
}
token, docType = ref.Token, ref.Type
if explicitType != "" && explicitType != docType {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
docType,
).WithParam("--type")
}
} else {
token = raw
}
if explicitType != "" {
docType = explicitType
}
if docType == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
if err := validatePermApplyToken(token); err != nil {
return "", "", err
}
return token, docType, nil
}
func parsePermApplyResourceURL(rawURL string) (common.ResourceRef, bool) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return common.ResourceRef{}, false
}
escapedPath := parsed.EscapedPath()
for _, resourceKind := range permApplyResourceKinds {
if !strings.HasPrefix(escapedPath, resourceKind.Path) {
continue
}
escapedToken := strings.TrimSuffix(strings.TrimPrefix(escapedPath, resourceKind.Path), "/")
if escapedToken == "" || strings.Contains(escapedToken, "/") {
return common.ResourceRef{}, false
}
token, err := url.PathUnescape(escapedToken)
if err != nil || token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: resourceKind.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func validatePermApplyToken(token string) error {
if err := validate.ResourceName(token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
if token == "." || strings.Contains(token, "/") {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token must be a non-dot single path segment",
).WithParam("--token")
}
return nil
}
// DriveApplyPermission applies to the document owner for view or edit access
// on behalf of the invoking user. Matches the open-apis endpoint
// /open-apis/drive/v1/permissions/:token/members/apply.
@@ -88,16 +154,19 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
var DriveApplyPermission = common.Shortcut{
Service: "drive",
Command: "+apply-permission",
Description: "Apply to the document owner for view or edit permission on a doc/sheet/file/wiki/bitable/docx/mindnote/slides",
Description: "Apply to the owner for view or edit permission on a Drive resource",
Risk: "write",
Scopes: []string{"docs:permission.member:apply"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "token", Desc: "target token or URL (docx/sheets/base/file/wiki/doc/mindnote/slides/page)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: permApplyTypes},
{Name: "perm", Desc: "permission to request", Required: true, Enum: []string{"view", "edit"}},
{Name: "remark", Desc: "optional note shown on the request card sent to the owner"},
},
Tips: []string{
"When --token is a URL, its path determines --type; a conflicting --type is rejected.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolvePermApplyTarget(runtime.Str("token"), runtime.Str("type"))
return err
@@ -109,7 +178,7 @@ var DriveApplyPermission = common.Shortcut{
}
body := buildPermApplyBody(runtime)
return common.NewDryRunAPI().
Desc("Apply to document owner for access").
Desc("Apply to resource owner for access").
POST("/open-apis/drive/v1/permissions/:token/members/apply").
Params(map[string]interface{}{"type": docType}).
Body(body).
@@ -131,7 +200,7 @@ var DriveApplyPermission = common.Shortcut{
body,
)
if err != nil {
return err
return decoratePermApplyError(err)
}
runtime.Out(data, nil)
return nil
@@ -148,3 +217,34 @@ func buildPermApplyBody(runtime *common.RuntimeContext) map[string]interface{} {
}
return body
}
func decoratePermApplyError(err error) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}
guidance := permApplyErrorGuidance(problem.Code)
if guidance == "" {
return err
}
if problem.Hint == "" {
problem.Hint = guidance
} else if !strings.Contains(problem.Hint, guidance) {
problem.Hint += "; " + guidance
}
return err
}
func permApplyErrorGuidance(code int) string {
switch code {
case 1063006:
return "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait for the daily quota to reset before retrying"
case 1063007:
return "this document does not accept a permission-apply request; verify the target and requested permission, or contact the owner directly"
default:
return ""
}
}

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