Compare commits

..

70 Commits

Author SHA1 Message Date
evandance
40a0a9de66 feat(enhancement): centralize HTTP transport policies (#2021) 2026-08-02 14:55:05 +08:00
liangshuo-1
a8ad44ba13 docs: remove broken Star History chart (#2141) 2026-08-01 11:42:24 +08:00
liangshuo-1
003d0f42f8 chore: release v1.0.81 (#2136) 2026-07-31 18:47:19 +08:00
wangweiming-01
7946e5c81d feat: support source file preview artifacts (#2085) 2026-07-31 17:52:31 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +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
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

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

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

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

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

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

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

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
377 changed files with 33084 additions and 10941 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

@@ -9,11 +9,40 @@ permissions:
contents: read
jobs:
# All platforms (incl. darwin keychain_signer) are CGO-free and cross-compiled
# on a single ubuntu runner in one goreleaser run (one checksums.txt). The
# darwin signer's runtime FFI is validated separately by the signer-test job.
goreleaser:
needs: signer-test-macos
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -30,50 +59,79 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
# Validate the macOS keychain signer on real hardware. The release binaries are
# cross-compiled on ubuntu (CGO-free purego FFI), so this is the only step that
# needs a Mac — and it gates the release rather than producing it.
signer-test-macos:
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- name: Keychain signer round-trip (CGO-free purego FFI)
run: LARK_KEYCHAIN_IT=1 CGO_ENABLED=0 go test -tags keychain_signer -run Keychain -v ./internal/keysigner/
publish-npm:
needs: goreleaser
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Include release checksums
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
publish-npm:
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

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

View File

@@ -5,63 +5,25 @@ before:
- python3 scripts/fetch_meta.py
builds:
# Linux & Windows: pure-Go TPM 2.0 signer is compiled in by default (no build
# tag), cross-compiled with CGO disabled — the binaries ship the platform key
# signer for private_key_jwt. windows/arm64 is the one exception: the sks
# Windows dependency stack (go-ole) has no arm64 support, so the signer file is
# arch-excluded there and that binary falls back to client_secret only.
- id: linux
binary: lark-cli
main: .
- binary: lark-cli
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- linux
goarch:
- amd64
- arm64
- riscv64
- id: windows
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- windows
goarch:
- amd64
- arm64
# macOS: the keychain signer calls Security.framework via runtime FFI (purego),
# so it is CGO-free, compiled into every darwin build (no build tag), and
# cross-compiles from the same ubuntu runner as linux/windows.
- id: darwin
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
- riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
format: zip
files:
- README.md
- LICENSE

View File

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

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -285,12 +285,31 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## Contributing
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).

View File

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

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

@@ -40,10 +40,6 @@ type LoginOptions struct {
var pollDeviceToken = larkauth.PollDeviceToken
var resolveLoginClientAuth = func(ctx context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) {
return larkauth.ClientAuthFromConfig(cfg).ResolveSigner(ctx)
}
// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{Factory: f}
@@ -269,11 +265,7 @@ func authLoginRun(opts *LoginOptions) error {
if err != nil {
return err
}
clientAuth, err := resolveLoginClientAuth(opts.Ctx, config)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
}
authResp, err := larkauth.RequestDeviceAuthorization(opts.Ctx, httpClient, clientAuth, config.Brand, finalScope, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
}
@@ -333,7 +325,7 @@ func authLoginRun(opts *LoginOptions) error {
// Step 3: Poll for token
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, clientAuth, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
@@ -406,10 +398,6 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
if err != nil {
return err
}
clientAuth, err := resolveLoginClientAuth(opts.Ctx, config)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %v", err).WithCause(err)
}
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
if err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
@@ -427,7 +415,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, clientAuth, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
if !result.OK {

View File

@@ -716,14 +716,6 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
originalResolve := resolveLoginClientAuth
resolveCalls := 0
resolveLoginClientAuth = func(_ context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) {
resolveCalls++
return larkauth.ClientAuthFromConfig(cfg), nil
}
t.Cleanup(func() { resolveLoginClientAuth = originalResolve })
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
@@ -786,9 +778,6 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
if err != nil {
t.Fatalf("no-wait authLoginRun() error = %v", err)
}
if resolveCalls != 1 {
t.Fatalf("no-wait client auth preparations = %d, want 1", resolveCalls)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
}
@@ -804,9 +793,6 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
if err != nil {
t.Fatalf("device-code authLoginRun() error = %v", err)
}
if resolveCalls != 2 {
t.Fatalf("split-flow client auth preparations = %d, want one per invocation", resolveCalls)
}
got := stderr.String()
for _, want := range []string{
"OK: 授权成功! 用户: tester (ou_user)",
@@ -861,7 +847,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
@@ -898,17 +884,9 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
originalResolve := resolveLoginClientAuth
resolveCalls := 0
resolveLoginClientAuth = func(_ context.Context, cfg *core.CliConfig) (larkauth.ClientAuth, error) {
resolveCalls++
return larkauth.ClientAuthFromConfig(cfg), nil
}
t.Cleanup(func() { resolveLoginClientAuth = originalResolve })
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}
@@ -941,9 +919,6 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
if err == nil {
t.Fatal("expected error for aborted authorization")
}
if resolveCalls != 1 {
t.Fatalf("blocking-flow client auth preparations = %d, want 1", resolveCalls)
}
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
}

View File

@@ -4,18 +4,12 @@
package config
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/charmbracelet/huh"
"github.com/gofrs/flock"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -28,14 +22,9 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
const bindCommitLockTimeout = 5 * time.Second
var bindCommitMu sync.Mutex
// BindOptions holds all inputs for config bind.
type BindOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Source string
AppID string
// Identity selects one of two presets — "bot-only" or "user-default" —
@@ -105,7 +94,6 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
# Interactive (terminal user) — TUI prompts for everything:
lark-cli config bind`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
return runF(opts)
@@ -151,11 +139,10 @@ func configBindRun(opts *BindOptions) error {
return nil
}
result, err := resolveAccount(opts, source)
appConfig, err := resolveAccount(opts, source)
if err != nil {
return err
}
appConfig := result.AppConfig
opts.Brand = string(appConfig.Brand)
if err := resolveIdentity(opts); err != nil {
@@ -164,20 +151,10 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
if err := validateBindResult(bindContext(opts), opts, result); err != nil {
return err
}
applyPreferences(appConfig, opts, priorLangForApp(existing.ConfigBytes, appConfig.AppId))
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
noticeUserDefaultRisk(opts)
return commitBinding(opts, result, existing.ConfigBytes, source, targetConfigPath)
}
func bindContext(opts *BindOptions) context.Context {
if opts != nil && opts.Ctx != nil {
return opts.Ctx
}
return context.Background()
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
}
// existingBinding is the outcome of checking whether a workspace was already
@@ -262,15 +239,9 @@ func finalizeSource(opts *BindOptions) (string, error) {
// notice on success so the caller still sees that a rebind happened.
// See existingBinding for the returned fields.
func reconcileExistingBinding(opts *BindOptions, source, configPath string) (existingBinding, error) {
oldConfigData, err := vfs.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return existingBinding{}, nil
}
return existingBinding{}, errs.NewConfigError(errs.SubtypeInvalidConfig,
"cannot read existing workspace config %s: %v", configPath, err).
WithHint("fix the file permissions or I/O error before binding").
WithCause(err)
oldConfigData, _ := vfs.ReadFile(configPath)
if oldConfigData == nil {
return existingBinding{}, nil
}
if opts.IsTUI {
@@ -293,7 +264,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
// enumerate candidates, pick one via the shared decision layer, and build a
// ready-to-persist AppConfig. Adding a new bind source only requires
// implementing SourceBinder — none of the logic below needs to change.
func resolveAccount(opts *BindOptions, source string) (*BindResult, error) {
func resolveAccount(opts *BindOptions, source string) (*core.AppConfig, error) {
binder, err := newBinder(source, opts)
if err != nil {
return nil, err
@@ -307,7 +278,7 @@ func resolveAccount(opts *BindOptions, source string) (*BindResult, error) {
if err != nil {
return nil, err
}
return binder.Build(bindContext(opts), *picked)
return binder.Build(picked.AppID)
}
// resolveIdentity ensures opts.Identity is set before applyPreferences runs.
@@ -418,21 +389,10 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
// wrong profile's preference into a re-bind when the workspace holds multiple
// named profiles and the active one disagrees with Apps[0].
func priorLang(previousConfigBytes []byte) i18n.Lang {
return priorLangForApp(previousConfigBytes, "")
}
func priorLangForApp(previousConfigBytes []byte, appID string) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
}
if appID != "" {
for i := range multi.Apps {
if multi.Apps[i].AppId == appID {
return multi.Apps[i].Lang
}
}
}
if app := multi.CurrentAppConfig(""); app != nil {
return app.Lang
}
@@ -440,16 +400,12 @@ func priorLangForApp(previousConfigBytes []byte, appID string) i18n.Lang {
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
// deferred provider-manifest commit for keyless binds, and a JSON success
// envelope. The write and provider commit are serialized across CLI processes;
// if the provider commit fails, the workspace write is rolled back before any
// success output so an existing binding remains usable.
func commitBinding(opts *BindOptions, result *BindResult, previousConfigBytes []byte, source, configPath string) error {
appConfig := result.AppConfig
multi, err := mergeBoundApp(appConfig, previousConfigBytes, opts.langExplicit)
if err != nil {
return err
}
// best-effort cleanup of stale keychain entries from the previous binding (if
// any), and a JSON success envelope. Cleanup runs only after the new config
// is durably written — if anything fails earlier, the old workspace stays
// usable.
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
@@ -458,38 +414,9 @@ func commitBinding(opts *BindOptions, result *BindResult, previousConfigBytes []
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
}
releaseCommitLock, err := acquireBindCommitLock(opts)
if err != nil {
return err
}
commitLockHeld := true
defer func() {
if commitLockHeld {
releaseCommitLock()
}
}()
if err := ensureBindingSnapshotUnchanged(configPath, previousConfigBytes); err != nil {
return err
}
newConfigBytes := append(data, '\n')
if err := validate.AtomicWrite(configPath, newConfigBytes, 0600); err != nil {
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
}
if result.commitProviderManifest != nil {
if err := result.commitProviderManifest(); err != nil {
rollbackErr := rollbackBindingConfig(configPath, previousConfigBytes, newConfigBytes)
if rollbackErr != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to persist keyless signer provider: %v; failed to restore workspace config: %v", err, rollbackErr).
WithCause(err)
}
return errs.NewInternalError(errs.SubtypeStorage,
"failed to persist keyless signer provider (workspace config restored): %v", err).
WithCause(err)
}
}
releaseCommitLock()
commitLockHeld = false
replaced := previousConfigBytes != nil
// uiMsg renders human-facing TUI text (stderr success banner). Follows
@@ -498,6 +425,10 @@ func commitBinding(opts *BindOptions, result *BindResult, previousConfigBytes []
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
if replaced {
cleanupKeychainFromData(opts.Factory.Keychain, previousConfigBytes, appConfig)
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
@@ -539,133 +470,6 @@ func commitBinding(opts *BindOptions, result *BindResult, previousConfigBytes []
return nil
}
func acquireBindCommitLock(opts *BindOptions) (func(), error) {
bindCommitMu.Lock()
lockDir := filepath.Join(core.GetBaseConfigDir(), "locks")
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
bindCommitMu.Unlock()
return nil, errs.NewInternalError(errs.SubtypeStorage,
"failed to create bind lock directory: %v", err).WithCause(err)
}
fileLock := flock.New(filepath.Join(lockDir, "config-bind.lock"))
ctx, cancel := context.WithTimeout(bindContext(opts), bindCommitLockTimeout)
locked, err := fileLock.TryLockContext(ctx, 50*time.Millisecond)
cancel()
if err != nil || !locked {
bindCommitMu.Unlock()
if err == nil {
err = context.DeadlineExceeded
}
return nil, errs.NewInternalError(errs.SubtypeStorage,
"failed to acquire config bind lock: %v", err).WithCause(err)
}
return func() {
_ = fileLock.Unlock()
bindCommitMu.Unlock()
}, nil
}
func ensureBindingSnapshotUnchanged(configPath string, previousConfigBytes []byte) error {
current, err := vfs.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) && previousConfigBytes == nil {
return nil
}
return errs.NewInternalError(errs.SubtypeStorage,
"failed to recheck workspace config %s before binding: %v", configPath, err).WithCause(err)
}
if previousConfigBytes != nil && bytes.Equal(current, previousConfigBytes) {
return nil
}
return errs.NewConfigError(errs.SubtypeInvalidConfig,
"workspace config %s changed while the bind was being validated", configPath).
WithHint("retry config bind using the latest workspace state")
}
func rollbackBindingConfig(configPath string, previousConfigBytes, writtenConfigBytes []byte) error {
current, err := vfs.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) && previousConfigBytes == nil {
return nil
}
//nolint:forbidigo // intermediate rollback diagnostic; commitBinding wraps it into a typed storage error
return fmt.Errorf("recheck workspace config before rollback: %w", err)
}
if !bytes.Equal(current, writtenConfigBytes) {
//nolint:forbidigo // intermediate rollback diagnostic; commitBinding wraps it into a typed storage error
return fmt.Errorf("workspace config changed after the bind write; refusing to overwrite it during rollback")
}
if previousConfigBytes != nil {
return validate.AtomicWrite(configPath, previousConfigBytes, 0600)
}
if err := vfs.Remove(configPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// mergeBoundApp upserts by unique appId and activates the target while
// preserving every non-target profile and root policy.
func mergeBoundApp(incoming *core.AppConfig, previousBytes []byte, langExplicit bool) (*core.MultiAppConfig, error) {
if incoming == nil || strings.TrimSpace(incoming.AppId) == "" {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "config bind produced an empty app")
}
if previousBytes == nil {
return &core.MultiAppConfig{Apps: []core.AppConfig{*incoming}, CurrentApp: incoming.ProfileName()}, nil
}
var multi core.MultiAppConfig
if err := json.Unmarshal(previousBytes, &multi); err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"cannot update malformed workspace config: %v", err).WithCause(err)
}
match := -1
for i := range multi.Apps {
if multi.Apps[i].AppId != incoming.AppId {
continue
}
if match >= 0 {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"appId %s appears in multiple CLI profiles", incoming.AppId).
WithHint("remove the duplicate profile before binding")
}
match = i
}
oldActive := ""
if active := multi.CurrentAppConfig(""); active != nil {
oldActive = active.ProfileName()
}
if match >= 0 {
old := multi.Apps[match]
incoming.Name = old.Name
incoming.Users = old.Users
if !langExplicit {
incoming.Lang = old.Lang
}
multi.Apps[match] = *incoming
} else {
for i := range multi.Apps {
if multi.Apps[i].Name != "" && multi.Apps[i].Name == incoming.AppId {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"new appId %s conflicts with existing profile name", incoming.AppId).
WithHint("rename the existing profile before binding")
}
}
incoming.Name = ""
incoming.Users = []core.AppUser{}
multi.Apps = append(multi.Apps, *incoming)
match = len(multi.Apps) - 1
}
targetName := multi.Apps[match].ProfileName()
if oldActive != targetName {
multi.PreviousApp = oldActive
multi.CurrentApp = targetName
}
return &multi, nil
}
// cleanupKeychainFromData removes keychain entries referenced by a previous
// config snapshot, skipping any entry whose keychain ID is still in use by
// the new app config. This prevents rebinding the same appId from deleting

View File

@@ -84,21 +84,6 @@ func saveWorkspace(t *testing.T) {
t.Cleanup(func() { core.SetCurrentWorkspace(orig) })
}
func TestReconcileExistingBinding_ReadFailureIsNotTreatedAsMissing(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
if err := os.Mkdir(configPath, 0700); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
_, err := reconcileExistingBinding(&BindOptions{Factory: f}, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "cannot read existing workspace config") {
t.Fatalf("reconcileExistingBinding error = %v", err)
}
if info, statErr := os.Stat(configPath); statErr != nil || !info.IsDir() {
t.Fatalf("unreadable existing config was changed: info=%v error=%v", info, statErr)
}
}
// ── Command flag parsing tests (aligned with config_test.go pattern) ──
func TestConfigBindCmd_FlagParsing(t *testing.T) {
@@ -1512,11 +1497,7 @@ func assertPresetApplied(t *testing.T, configPath string, wantStrict core.Strict
if len(multi.Apps) == 0 {
t.Fatalf("no apps in %s", configPath)
}
appPtr := multi.CurrentAppConfig("")
if appPtr == nil {
t.Fatalf("no current app in %s", configPath)
}
app := *appPtr
app := multi.Apps[0]
if app.StrictMode == nil || *app.StrictMode != wantStrict {
t.Errorf("StrictMode = %v, want %q", app.StrictMode, wantStrict)
}

View File

@@ -4,7 +4,6 @@
package config
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -24,19 +23,6 @@ type Candidate struct {
Label string
}
// BindResult carries the selected app. External signer configuration is the
// logical provider on AppConfig.KeyRef; bind never persists executable paths.
type BindResult struct {
AppConfig *core.AppConfig
// commitProviderManifest is populated only after a keyless bind probe has
// authenticated successfully. commitBinding runs it after the workspace
// config write and rolls that write back if the global provider index cannot
// be committed, so a failed bind never changes the signer used by existing
// applications.
commitProviderManifest func() error
}
// SourceBinder abstracts a bind source (openclaw / hermes / future sources).
// Implementations only list candidates and build an AppConfig for a chosen
// candidate — they stay out of mode (TUI vs flag) and orchestration concerns.
@@ -48,9 +34,9 @@ type SourceBinder interface {
// ListCandidates enumerates bindable accounts from the source config.
// An empty slice is valid (selectCandidate will turn it into a typed error).
ListCandidates() ([]Candidate, error)
// Build resolves credentials and returns the app plus any signer command
// needed by the workspace. Must be called after ListCandidates succeeds.
Build(ctx context.Context, candidate Candidate) (*BindResult, error)
// Build resolves secrets, persists to keychain, and returns a ready AppConfig
// for the chosen candidate AppID. Must be called after ListCandidates succeeds.
Build(appID string) (*core.AppConfig, error)
}
// newBinder constructs the SourceBinder for the given source name.
@@ -107,21 +93,11 @@ func selectCandidate(
}
if appIDFlag != "" {
var matches []Candidate
for i := range candidates {
if candidates[i].AppID == appIDFlag {
matches = append(matches, candidates[i])
return &candidates[i], nil
}
}
if len(matches) == 1 {
return &matches[0], nil
}
if len(matches) > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--app-id %q matches multiple accounts in %s", appIDFlag, cfgBase).
WithHint("run 'lark-cli config bind' interactively to choose an account, or configure unique app IDs:\n %s", formatCandidates(matches)).
WithParam("--app-id")
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
WithParam("--app-id")
@@ -192,48 +168,20 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
return result, nil
}
func (b *openclawBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) {
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
var selected *binding.CandidateApp
for i := range b.rawApps {
if b.rawApps[i].AppID == candidate.AppID && b.rawApps[i].Label == candidate.Label {
if b.rawApps[i].AppID == appID {
selected = &b.rawApps[i]
break
}
}
if selected == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError,
"internal: account %q (appID %q) not in candidates", candidate.Label, candidate.AppID)
}
if selected.AuthMethod != "" && selected.AuthMethod != "app_secret" && selected.AuthMethod != binding.AuthMethodPrivateKeyJWT {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q for app %s in %s", selected.AuthMethod, selected.AppID, b.path).
WithHint("supported values are app_secret and private_key_jwt")
}
// openclaw-lark deliberately gives appSecret precedence when both shapes
// are present. Reproduce that behavior so bind never changes the effective
// credential type merely because authMethod was left stale.
if selected.AppSecret.IsZero() && selected.AuthMethod == binding.AuthMethodPrivateKeyJWT {
if strings.TrimSpace(selected.KeyRef) == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"private_key_jwt app %s in %s is missing keyRef", selected.AppID, b.path).
WithHint("re-run OpenClaw onboarding so the keyless account records its signer keyRef")
}
return &BindResult{
AppConfig: &core.AppConfig{
AppId: selected.AppID,
Brand: core.ParseBrand(selected.Brand),
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyRef: &core.SecretRef{
Source: core.SecretSourceTEE,
Provider: core.KeylessProviderLarkSuite,
ID: strings.TrimSpace(selected.KeyRef),
},
},
}, nil
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
}
if selected.AppSecret.IsZero() {
@@ -254,11 +202,11 @@ func (b *openclawBinder) Build(_ context.Context, candidate Candidate) (*BindRes
WithCause(err)
}
return &BindResult{AppConfig: &core.AppConfig{
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.ParseBrand(selected.Brand),
}}, nil
}, nil
}
// ──────────────────────────────────────────────────────────────
@@ -290,8 +238,7 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: appID, Label: "default"}}, nil
}
func (b *hermesBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) {
appID := candidate.AppID
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
if b.envMap == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -311,11 +258,11 @@ func (b *hermesBinder) Build(_ context.Context, candidate Candidate) (*BindResul
WithCause(err)
}
return &BindResult{AppConfig: &core.AppConfig{
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
}}, nil
}, nil
}
// ──────────────────────────────────────────────────────────────
@@ -348,8 +295,7 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
}
func (b *larkChannelBinder) Build(_ context.Context, candidate Candidate) (*BindResult, error) {
appID := candidate.AppID
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
@@ -377,11 +323,11 @@ func (b *larkChannelBinder) Build(_ context.Context, candidate Candidate) (*Bind
WithCause(err)
}
return &BindResult{AppConfig: &core.AppConfig{
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
}}, nil
}, nil
}
// ──────────────────────────────────────────────────────────────

View File

@@ -4,12 +4,10 @@
package config
import (
"context"
"path/filepath"
"reflect"
"testing"
"github.com/larksuite/cli/internal/binding"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
@@ -22,10 +20,10 @@ type fakeBinder struct {
path string
}
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(context.Context, Candidate) (*BindResult, error) { return nil, nil }
func (b *fakeBinder) Name() string { return b.name }
func (b *fakeBinder) ConfigPath() string { return b.path }
func (b *fakeBinder) ListCandidates() ([]Candidate, error) { return nil, nil }
func (b *fakeBinder) Build(appID string) (*core.AppConfig, error) { return nil, nil }
// tuiUnreachable is a tuiPrompt that fails the test if called. It's the
// guardrail that proves the non-TUI decision paths really do stay out of the
@@ -109,20 +107,6 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
})
}
func TestSelectCandidate_AppIDFlag_RejectsDuplicateInheritedAppID(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{
{AppID: "cli_shared", Label: "work"},
{AppID: "cli_shared", Label: "personal"},
}
_, err := selectCandidate(b, candidates, "cli_shared", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "cli_shared" matches multiple accounts in openclaw.json`,
Hint: "run 'lark-cli config bind' interactively to choose an account, or configure unique app IDs:\n cli_shared (work)\n cli_shared (personal)",
})
}
func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
// Flag-mode with multiple candidates and no --app-id must produce a
// validation error and the candidate list, never an interactive prompt.
@@ -191,27 +175,6 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}
func TestOpenClawBuildUsesSelectedLabelWhenAppIDIsShared(t *testing.T) {
b := &openclawBinder{
cfg: &binding.OpenClawRoot{},
rawApps: []binding.CandidateApp{
{Label: "work", AppID: "cli_shared", AuthMethod: binding.AuthMethodPrivateKeyJWT, KeyRef: "work-key"},
{Label: "personal", AppID: "cli_shared", AuthMethod: binding.AuthMethodPrivateKeyJWT, KeyRef: "personal-key"},
},
}
result, err := b.Build(context.Background(), Candidate{AppID: "cli_shared", Label: "personal"})
if err != nil {
t.Fatal(err)
}
if result.AppConfig.KeyRef == nil || result.AppConfig.KeyRef.ID != "personal-key" {
t.Fatalf("keyRef = %#v, want personal-key", result.AppConfig.KeyRef)
}
if result.AppConfig.KeyRef.Provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q, want %q", result.AppConfig.KeyRef.Provider, core.KeylessProviderLarkSuite)
}
}
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -65,39 +65,6 @@ func TestConfigInitCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigInitCmd_PrivateKeyJWTFlag(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--new", "--private-key-jwt"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.PrivateKeyJWT {
t.Error("PrivateKeyJWT = false, want true")
}
}
func TestConfigInitCmd_AuthMethodFlagRemoved(t *testing.T) {
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { return nil })
cmd.SetArgs([]string{"--new", "--auth-method", core.AuthMethodPrivateKeyJWT})
err := cmd.Execute()
if err == nil {
t.Fatal("expected unknown flag error")
}
if !strings.Contains(err.Error(), "unknown flag: --auth-method") {
t.Fatalf("error = %v, want unknown --auth-method flag", err)
}
}
func TestConfigShowCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -226,7 +193,7 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, "", "", nil); err != nil {
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
@@ -239,68 +206,6 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
}
}
func TestKeyRefFromResult_PrivateKeyJWT(t *testing.T) {
ref := keyRefFromResult(&configInitResult{
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "lark-cli-default",
})
if ref == nil {
t.Fatal("keyRefFromResult returned nil")
}
if ref.Source != "tee" || ref.ID != "lark-cli-default" {
t.Fatalf("key ref = %#v, want tee/lark-cli-default", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodPrivateKeyJWT}); ref != nil {
t.Fatalf("missing key label should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodClientSecret, KeyLabel: "ignored"}); ref != nil {
t.Fatalf("client_secret should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(nil); ref != nil {
t.Fatalf("nil result should not persist key ref, got %#v", ref)
}
}
func TestPersistInitResult_PrivateKeyJWT(t *testing.T) {
for _, tc := range []struct {
name string
profile string
brand core.LarkBrand
}{
{name: "single app", brand: core.BrandFeishu},
{name: "named profile", profile: "prod", brand: core.BrandLark},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "en_us"}
result := &configInitResult{
Brand: tc.brand, AppID: "cli_pkjwt",
AuthMethod: core.AuthMethodPrivateKeyJWT, KeyLabel: "lark-cli-default",
}
if err := persistInitResult(opts, f, tc.profile, result); err != nil {
t.Fatal(err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
app := got.CurrentAppConfig(tc.profile)
if app == nil || app.AppId != "cli_pkjwt" || app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("saved app = %#v", app)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if !app.AppSecret.IsZero() {
t.Fatalf("private_key_jwt config must stay secretless, AppSecret value %#v", app.AppSecret)
}
})
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
@@ -483,7 +388,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
},
}
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "", nil)
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
if err == nil {
t.Fatal("expected conflict error")
}
@@ -522,46 +427,6 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
}
}
func TestSaveAsProfile_UpdatePersistsPrivateKeyJWT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "prod",
AppId: "cli_prod",
AppSecret: core.PlainSecret("old-secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
}},
}
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "prod", "cli_prod", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveAsProfile update private_key_jwt: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := got.FindApp("prod")
if app == nil {
t.Fatalf("profile prod not saved: %#v", got.Apps)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt update must stay secretless, AppSecret value %#v", app.AppSecret)
}
if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_1" {
t.Fatalf("same-app update should preserve users, Users=%#v", app.Users)
}
}
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "prod",

View File

@@ -19,7 +19,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
)
@@ -32,7 +31,6 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
PrivateKeyJWT bool // --private-key-jwt: request private_key_jwt instead of the default client_secret
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
@@ -41,8 +39,6 @@ type ConfigInitOptions struct {
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Restore bool // Restore re-registers the app already in config to recover a lost credential
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
@@ -85,26 +81,17 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
}
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().BoolVar(&opts.PrivateKeyJWT, "private-key-jwt", false, "create a new app with private_key_jwt (signed by a platform key, no app secret)")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
return cmd
}
func requestedInitAuthMethod(opts *ConfigInitOptions) string {
if opts.PrivateKeyJWT {
return core.AuthMethodPrivateKeyJWT
}
return core.AuthMethodClientSecret
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
@@ -145,7 +132,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin
return o.New || o.AppID != "" || o.AppSecretStdin
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
@@ -164,61 +151,22 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
}
}
// removeStaleSecretForPKJWT clears a secret left in the keychain when the SAME
// appId is migrated from client_secret to private_key_jwt. cleanupOldConfig
// explicitly skips a matching appId, and saveAsProfile only cleans up on an
// appId change, so a same-appId migration would orphan the old secret. This
// fills that gap. RemoveSecretStore only deletes Source=="keychain" entries, so
// the new pkjwt tee key handle is never touched.
func removeStaleSecretForPKJWT(existing *core.MultiAppConfig, profileName, appID string, kc keychain.KeychainAccess) {
if existing == nil {
return
}
var prior *core.AppConfig
if profileName != "" {
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
prior = &existing.Apps[idx]
}
} else {
prior = existing.CurrentAppConfig("")
}
if prior != nil && prior.AppId == appID && !prior.AppSecret.IsZero() {
core.RemoveSecretStore(prior.AppSecret, kc)
}
}
// keyRefFromResult builds the TEE key reference to persist for a private_key_jwt
// registration result, or nil for client_secret.
func keyRefFromResult(r *configInitResult) *core.SecretRef {
if r != nil && r.AuthMethod == core.AuthMethodPrivateKeyJWT && r.KeyLabel != "" {
return &core.SecretRef{Source: "tee", ID: r.KeyLabel}
}
return nil
}
// saveAsOnlyApp overwrites config.json with a single-app config.
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
AuthMethod: authMethod, KeyRef: keyRef,
}},
}
return saveMultiAppConfigForInit(config)
}
func saveMultiAppConfigForInit(config *core.MultiAppConfig) error {
return core.SaveMultiAppConfig(config)
}
// saveInitConfig saves a new/updated app config, respecting --profile mode.
// With profileName: appends or updates the named profile (preserves other profiles).
// Without profileName: cleans up old config and saves as the only app.
// authMethod/keyRef carry the credential type: ("", nil) for client_secret,
// (private_key_jwt, &{tee,label}) for the secretless TEE flow.
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
if profileName != "" {
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, authMethod, keyRef)
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
var prior i18n.Lang
@@ -227,7 +175,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)), authMethod, keyRef)
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
@@ -247,7 +195,7 @@ func wrapSaveConfigError(err error) error {
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
multi := existing
if multi == nil {
multi = &core.MultiAppConfig{}
@@ -266,8 +214,6 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
multi.Apps[idx].AuthMethod = authMethod
multi.Apps[idx].KeyRef = keyRef
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
@@ -276,17 +222,15 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
AuthMethod: authMethod,
KeyRef: keyRef,
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})
}
return saveMultiAppConfigForInit(multi)
return core.SaveMultiAppConfig(multi)
}
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
@@ -358,141 +302,12 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
app.AppId = appID
app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
return saveMultiAppConfigForInit(existing)
}
func persistInitResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
existing, _ := core.LoadMultiAppConfig()
switch {
case result.AuthMethod == core.AuthMethodPrivateKeyJWT:
if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
return wrapSaveConfigError(err)
}
removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain)
return nil
case result.AppSecret != "":
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
}
return nil
case result.Mode == "existing" && result.AppID != "":
return wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, profileName, result.AppID, result.Brand, opts.Lang))
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").WithParam("--app-id")
}
}
func probeInitResult(opts *ConfigInitOptions, f *cmdutil.Factory, result *configInitResult) error {
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel)
}
if result.AppSecret != "" {
return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand)
}
return nil
}
// persistAndProbeResult saves a registration/restore result into profileName and
// runs the post-registration probe. profileName == "" replaces the single app
// (legacy); a named profile is updated in place. Shared by --new and --restore.
func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
if err := persistInitResult(opts, f, profileName, result); err != nil {
return err
}
printLangPreferenceConfirmation(opts)
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
}
return probeInitResult(opts, f, result)
}
// runRestoreFlow re-registers the app already in config to recover a lost
// credential (deleted keychain key / lost app secret). It reads the existing
// app id + auth method + brand from config (no secret needed — that's the lost
// part) and re-runs the device-flow registration with the app id sent on begin,
// so the server re-registers that app instead of creating a new one. The
// re-issued credential is written back to the same profile.
func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error {
if existing == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found").
WithHint("run: lark-cli config init")
}
app := existing.CurrentAppConfig(opts.ProfileName)
if app == nil || app.AppId == "" {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)).
WithHint("run: lark-cli config init")
}
if app.KeyRef != nil && strings.TrimSpace(app.KeyRef.Provider) != "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"config init --restore does not manage external signer provider %q", app.KeyRef.Provider).
WithHint("repair the OpenClaw provider with onboarding doctor --fix, then run config bind again")
}
restoreAppID := app.AppId
// Reuse the stored auth method authoritatively — never prompt. Empty on disk
// means client_secret (omitempty back-compat); pass it explicitly so restore
// preserves the existing credential type.
authMethod := app.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result")
}
// Safety: if the server did not honor app_id (e.g. not yet supported), it may
// have created a NEW app instead of restoring. Warn so the user is not silently
// switched to a different app id.
if result.AppID != restoreAppID {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID)
}
// Write back to the profile we restored: an explicit --name, else the resolved
// app's own name. Empty name => legacy single-app replace.
saveProfile := opts.ProfileName
if saveProfile == "" {
saveProfile = app.Name
}
return persistAndProbeResult(opts, f, saveProfile, result)
}
// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed.
func profileSuffix(profileName string) string {
if profileName == "" {
return ""
}
return fmt.Sprintf(" (profile %q)", profileName)
return core.SaveMultiAppConfig(existing)
}
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
if opts.PrivateKeyJWT {
switch {
case opts.Restore:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--private-key-jwt cannot be combined with --restore; restore preserves the stored auth method").
WithParam("--private-key-jwt")
case opts.AppID != "":
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--private-key-jwt cannot be combined with --app-id; use --new to register a private_key_jwt app").
WithParam("--private-key-jwt")
case opts.AppSecretStdin:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--private-key-jwt cannot be combined with --app-secret-stdin; private_key_jwt does not use an app secret").
WithParam("--private-key-jwt")
}
}
// Read secret from stdin if --app-secret-stdin is set
if opts.AppSecretStdin {
scanner := bufio.NewScanner(f.IOStreams.In)
@@ -520,26 +335,6 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
// --restore recovers an existing app; it is incompatible with creating a new
// app (--new) or importing one non-interactively (--app-id / stdin secret).
if opts.Restore {
if opts.New {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore")
}
if opts.AppID != "" || opts.AppSecretStdin {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore")
}
}
// A user who explicitly asks for private_key_jwt needs immediate feedback
// before any interactive prompt. Otherwise unsupported machines enter the
// TUI and fail only after the user chooses a create flow.
if opts.PrivateKeyJWT && !opts.New && !opts.Restore {
if _, err := resolveRegisterAuthMethod(opts.Ctx, f, core.AuthMethodPrivateKeyJWT); err != nil {
return err
}
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
@@ -547,7 +342,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, "", nil); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
@@ -573,26 +368,34 @@ func configInitRun(opts *ConfigInitOptions) error {
msg := getInitMsg(opts.UILang)
// Mode: Restore (--restore) — re-register the app already in config.
if opts.Restore {
return runRestoreFlow(opts, existing, f, msg)
}
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), requestedInitAuthMethod(opts), msg, "")
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
}
return persistAndProbeResult(opts, f, opts.ProfileName, result)
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
// Mode 4: Interactive TUI (terminal)
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
result, err := runInteractiveConfigInit(opts.Ctx, f, requestedInitAuthMethod(opts), msg)
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
if err != nil {
return err
}
@@ -601,21 +404,35 @@ func configInitRun(opts *ConfigInitOptions) error {
WithParam("--app-id")
}
if err := persistInitResult(opts, f, opts.ProfileName, result); err != nil {
return err
}
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
if err := probeInitResult(opts, f, result); err != nil {
existing, _ := core.LoadMultiAppConfig()
if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
return err
}
} else {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
WithParam("--app-id")
}
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
if result.AuthMethod != core.AuthMethodPrivateKeyJWT {
return probeInitResult(opts, f, result)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil
}
@@ -700,7 +517,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, "", nil); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))

View File

@@ -1,306 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
type authMethodTestSigner struct {
info keysigner.HardwareInfo
probeErr error
}
func (authMethodTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) Sign(context.Context, keysigner.KeyRef, []byte) ([]byte, string, error) {
return nil, "", nil
}
func (s authMethodTestSigner) ProbeHardware(context.Context) (keysigner.HardwareInfo, error) {
return s.info, s.probeErr
}
// TestResolveRegisterAuthMethod covers the non-interactive gating paths. The
// darwin keychain signer is compiled into every build, so the test cannot rely
// on the binary lacking a signer — it forces a known no-signer state for the
// rejection cases, then registers a stub for the success case.
func TestResolveRegisterAuthMethod(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := &cmdutil.Factory{}
ctx := context.Background()
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(nil)
if m, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodClientSecret); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("client_secret: got (%q, %v), want (client_secret, nil)", m, err)
}
if m, err := resolveRegisterAuthMethod(ctx, f, ""); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("default: got (%q, %v), want (client_secret, nil)", m, err)
}
if _, err := resolveRegisterAuthMethod(ctx, f, "bogus"); err == nil {
t.Error("bogus auth-method: expected error")
}
if _, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodPrivateKeyJWT); err == nil {
t.Error("private_key_jwt without a signer: expected error")
}
keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{Backend: "tpm2", Available: true}})
if m, err := resolveRegisterAuthMethod(ctx, f, core.AuthMethodPrivateKeyJWT); err != nil || m != core.AuthMethodPrivateKeyJWT {
t.Errorf("private_key_jwt with signer: got (%q, %v), want (private_key_jwt, nil)", m, err)
}
f.IOStreams = &cmdutil.IOStreams{IsTerminal: true}
if m, err := resolveRegisterAuthMethod(ctx, f, ""); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("default with terminal signer: got (%q, %v), want (client_secret, nil)", m, err)
}
}
func TestConfigInitRunRejectsPrivateKeyJWTIncompatibleModes(t *testing.T) {
tests := []struct {
name string
configure func(*ConfigInitOptions, *cmdutil.Factory)
wantTarget string
}{
{
name: "app id import",
configure: func(opts *ConfigInitOptions, _ *cmdutil.Factory) {
opts.AppID = "cli_test"
},
wantTarget: "--app-id",
},
{
name: "app secret stdin import",
configure: func(opts *ConfigInitOptions, f *cmdutil.Factory) {
opts.AppSecretStdin = true
f.IOStreams.In = strings.NewReader("secret\n")
},
wantTarget: "--app-secret-stdin",
},
{
name: "restore",
configure: func(opts *ConfigInitOptions, _ *cmdutil.Factory) {
opts.Restore = true
},
wantTarget: "--restore",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{
Factory: f,
Ctx: context.Background(),
PrivateKeyJWT: true,
}
tc.configure(opts, f)
err := configInitRun(opts)
if err == nil {
t.Fatal("expected incompatible mode error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--private-key-jwt" {
t.Fatalf("param = %q, want --private-key-jwt", validationErr.Param)
}
if !strings.Contains(problem.Message, tc.wantTarget) {
t.Fatalf("message = %q, want %s", problem.Message, tc.wantTarget)
}
})
}
}
func TestResolveRegisterAuthMethod_PrivateKeyJWTRejectsUnavailableHardware(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{
Backend: "tpm2",
Reason: "open /dev/tpmrm0: permission denied",
}})
_, err := resolveRegisterAuthMethod(context.Background(), &cmdutil.Factory{}, core.AuthMethodPrivateKeyJWT)
if err == nil {
t.Fatal("private_key_jwt with unavailable signer hardware: expected error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient {
t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype)
}
wantMessage := "this machine does not support --private-key-jwt"
if problem.Message != wantMessage {
t.Fatalf("message = %q, want %q", problem.Message, wantMessage)
}
if strings.Contains(problem.Message, "sks") || strings.Contains(problem.Message, "/dev/tpm") || strings.Contains(problem.Message, "tpm") || strings.Contains(problem.Message, "TEE") || strings.Contains(problem.Message, "Keychain") {
t.Fatalf("message exposes backend detail: %q", problem.Message)
}
if !strings.Contains(problem.Hint, "omit --private-key-jwt") {
t.Fatalf("hint = %q, want guidance to omit --private-key-jwt", problem.Hint)
}
if strings.Contains(problem.Hint, "fix the local signer") {
t.Fatalf("hint exposes unnecessary signer recovery: %q", problem.Hint)
}
}
func TestResolveRegisterAuthMethod_PrivateKeyJWTRejectsProbeError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
probeErr := errors.New("probe exploded")
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(authMethodTestSigner{
info: keysigner.HardwareInfo{Backend: "keychain"},
probeErr: probeErr,
})
_, err := resolveRegisterAuthMethod(context.Background(), &cmdutil.Factory{}, core.AuthMethodPrivateKeyJWT)
if err == nil {
t.Fatal("private_key_jwt with probe error: expected error")
}
if !errors.Is(err, probeErr) {
t.Fatalf("error does not preserve probe cause: %v", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient {
t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype)
}
wantMessage := "this machine does not support --private-key-jwt"
if problem.Message != wantMessage {
t.Fatalf("message = %q, want %q", problem.Message, wantMessage)
}
if strings.Contains(problem.Message, "probe") || strings.Contains(problem.Message, "keychain signer") {
t.Fatalf("message exposes probe detail: %q", problem.Message)
}
if !strings.Contains(problem.Hint, "omit --private-key-jwt") {
t.Fatalf("hint = %q, want guidance to omit --private-key-jwt", problem.Hint)
}
if strings.Contains(problem.Hint, "fix the local signer") {
t.Fatalf("hint exposes unnecessary signer recovery: %q", problem.Hint)
}
}
func TestConfigInitRun_PrivateKeyJWTRejectsBeforeInteractiveMode(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(authMethodTestSigner{info: keysigner.HardwareInfo{
Backend: "tpm2",
Reason: "not available",
}})
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.IsTerminal = true
opts := &ConfigInitOptions{
Factory: f,
Ctx: context.Background(),
PrivateKeyJWT: true,
Lang: "zh_cn",
UILang: "zh_cn",
}
err := configInitRun(opts)
if err == nil {
t.Fatal("config init --private-key-jwt on unsupported machine: expected error before interactive mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %[1]v", err)
}
if problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient {
t.Fatalf("problem = %s/%s, want config/invalid_client", problem.Category, problem.Subtype)
}
if problem.Message != "this machine does not support --private-key-jwt" {
t.Fatalf("message = %q", problem.Message)
}
}
func TestExistingAppRequiresSecret(t *testing.T) {
if !existingAppRequiresSecret(core.AuthMethodClientSecret) {
t.Error("client_secret existing app should require App Secret")
}
if existingAppRequiresSecret("") != true {
t.Error("default existing app should require App Secret")
}
if existingAppRequiresSecret(core.AuthMethodPrivateKeyJWT) {
t.Error("private_key_jwt existing app should not require App Secret")
}
}
// TestValidatePKJWTKeyBinding covers the guard that rejects a registration
// resolving to private_key_jwt with no signing key bound (e.g. an existing
// secret-based app was selected on the confirm page).
func TestValidatePKJWTKeyBinding(t *testing.T) {
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, ""); err == nil {
t.Error("pkjwt with empty keyLabel: expected error")
}
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, "agent-key"); err != nil {
t.Errorf("pkjwt with keyLabel: expected nil, got %v", err)
}
if err := validatePKJWTKeyBinding(core.AuthMethodClientSecret, ""); err != nil {
t.Errorf("client_secret: expected nil, got %v", err)
}
}
// TestResolveFinalAuthMethod locks the authoritative-method logic. The 2nd case
// is the real bug: we requested private_key_jwt but the server resolved to an
// existing client_secret app — we must persist client_secret, not pkjwt.
func TestResolveFinalAuthMethod(t *testing.T) {
if m := resolveFinalAuthMethod([]string{"client_secret", "private_key_jwt"}, core.AuthMethodClientSecret); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("prefers private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod([]string{"client_secret"}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodClientSecret {
t.Errorf("server client_secret must override requested pkjwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("fallback to requested when server is silent: got %q", m)
}
// Explicit empty slice (not just nil) also falls back to requested — the same
// len()==0 back-compat allowance the init guard relies on to let private_key_jwt
// proceed against an older server (see internal/auth
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods).
if m := resolveFinalAuthMethod([]string{}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("empty []string should fall back to requested private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, ""); m != core.AuthMethodClientSecret {
t.Errorf("default to client_secret: got %q", m)
}
}

View File

@@ -8,9 +8,6 @@ import (
"errors"
"fmt"
"net"
"slices"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
@@ -18,26 +15,22 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// configInitResult holds the result of the interactive config init flow.
type configInitResult struct {
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle when AuthMethod == private_key_jwt
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
}
// runInteractiveConfigInit shows an interactive TUI for config init.
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Phase 1: Choose mode
var mode string
form1 := huh.NewForm(
@@ -60,18 +53,14 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMetho
}
if mode == "existing" {
return runExistingAppForm(ctx, f, authMethodFlag, msg)
return runExistingAppForm(f, msg)
}
return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "")
}
func existingAppRequiresSecret(requestedAuthMethod string) bool {
return requestedAuthMethod != core.AuthMethodPrivateKeyJWT
return runCreateAppFlow(ctx, f, "", msg)
}
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
func runExistingAppForm(ctx context.Context, f *cmdutil.Factory, requestedAuthMethod string, msg *initMsg) (*configInitResult, error) {
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Load existing config for defaults
existing, _ := core.LoadMultiAppConfig()
var firstApp *core.AppConfig
@@ -105,31 +94,19 @@ func runExistingAppForm(ctx context.Context, f *cmdutil.Factory, requestedAuthMe
brand = string(firstApp.Brand)
}
brandSelect := huh.NewSelect[string]().
Title(msg.Platform).
Options(
huh.NewOption(msg.Feishu, "feishu"),
huh.NewOption("Lark", "lark"),
).
Value(&brand)
var form *huh.Form
if existingAppRequiresSecret(requestedAuthMethod) {
form = huh.NewForm(
huh.NewGroup(
appIDInput,
appSecretInput,
brandSelect,
),
).WithTheme(cmdutil.ThemeFeishu())
} else {
form = huh.NewForm(
huh.NewGroup(
appIDInput,
brandSelect,
),
).WithTheme(cmdutil.ThemeFeishu())
}
form := huh.NewForm(
huh.NewGroup(
appIDInput,
appSecretInput,
huh.NewSelect[string]().
Title(msg.Platform).
Options(
huh.NewOption(msg.Feishu, "feishu"),
huh.NewOption("Lark", "lark"),
).
Value(&brand),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
@@ -142,13 +119,6 @@ func runExistingAppForm(ctx context.Context, f *cmdutil.Factory, requestedAuthMe
if appID == "" && firstApp != nil {
appID = firstApp.AppId
}
if !existingAppRequiresSecret(requestedAuthMethod) {
if appID == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
WithParam("--app-id")
}
return runCreateAppFlow(ctx, f, parseBrand(brand), core.AuthMethodPrivateKeyJWT, msg, appID)
}
if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() {
// Keep existing secret - caller will handle
return &configInitResult{
@@ -178,49 +148,9 @@ func runExistingAppForm(ctx context.Context, f *cmdutil.Factory, requestedAuthMe
}, nil
}
// resolveRegisterAuthMethod decides the auth method for a new-app registration.
// An explicit private_key_jwt request wins; otherwise the default is
// client_secret with no extra prompt.
func resolveRegisterAuthMethod(ctx context.Context, _ *cmdutil.Factory, requested string) (string, error) {
const pkjwtUnsupportedMessage = "this machine does not support --private-key-jwt"
switch requested {
case core.AuthMethodPrivateKeyJWT:
info, ok, err := keysigner.ProbeActiveHardware(ctx)
if !ok {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
pkjwtUnsupportedMessage).
WithHint("omit --private-key-jwt to register with an app secret")
}
if err != nil {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
pkjwtUnsupportedMessage).
WithCause(err).
WithHint("omit --private-key-jwt to register with an app secret")
}
if !info.Available {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
pkjwtUnsupportedMessage).
WithHint("omit --private-key-jwt to register with an app secret")
}
return core.AuthMethodPrivateKeyJWT, nil
case core.AuthMethodClientSecret:
return core.AuthMethodClientSecret, nil
case "":
return core.AuthMethodClientSecret, nil
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown auth method %q (use client_secret or private_key_jwt)", requested)
}
}
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
// requestedAuthMethod is the requested auth method; empty means client_secret.
// restoreAppID, when non-empty, is sent on the registration begin request so the
// server re-registers that existing app (credential recovery) instead of creating
// a new one. Empty preserves the normal new-app flow.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, requestedAuthMethod string, msg *initMsg, restoreAppID string) (*configInitResult, error) {
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
var larkBrand core.LarkBrand
if brandOverride != "" {
larkBrand = brandOverride
@@ -248,57 +178,17 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
larkBrand = parseBrand(brand)
}
authMethod, err := resolveRegisterAuthMethod(ctx, f, requestedAuthMethod)
if err != nil {
return nil, err
}
// Step 1: Request app registration (begin).
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
// Step 1: Request app registration (begin)
// Registration is platform traffic, so it must use the provider-aware
// transport as well as the shared proxy configuration.
httpClient := transport.NewHTTPClient(0)
// For private_key_jwt: init to obtain a nonce, then sign a TEE attestation
// (carrying the public key in its jwk header) to send with begin.
beginOpts := larkauth.AppRegistrationBeginOptions{}
keyLabel := ""
if authMethod == core.AuthMethodPrivateKeyJWT {
initResp, initErr := larkauth.RequestAppRegistrationInit(ctx, httpClient)
if initErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr)
}
// An empty SupportedAuthMethods is intentionally treated as "older server /
// unknown": len()==0 makes this guard false, so the requested
// private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's
// back-compat fallback to the requested method. Only an explicit list that
// omits private_key_jwt rejects here.
if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
"server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")).
WithHint("omit --private-key-jwt to register with an app secret instead")
}
keyLabel = keysigner.DefaultKeyLabel
signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod
attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now())
if signErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr)
}
beginOpts = larkauth.AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: attestation,
}
}
// Restore flow: re-register the existing app instead of creating a new one.
beginOpts.RestoreAppID = restoreAppID
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationBeginError(err)
}
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version, restoreAppID)
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
// Branch on TTY: human-friendly copy in interactive terminals,
// preserve original copy for AI / non-interactive callers.
@@ -327,42 +217,18 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
return nil, classifyRegistrationError(err)
}
// The final auth method is decided by the user/admin at confirmation and
// returned by poll — NOT necessarily what we requested. Selecting an existing
// client_secret app, for example, yields client_secret even though we sent
// private_key_jwt. Trust the result so we persist the truth.
finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod)
if result.ClientID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing app_id")
}
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret")
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
// Surface a downgrade: requested private_key_jwt but the app resolved to a
// secret-based method (e.g. an existing app was selected). The key was NOT
// bound, so we must store the secret method, not private_key_jwt.
if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod)
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
keyToStore := ""
if finalMethod == core.AuthMethodPrivateKeyJWT {
keyToStore = keyLabel
}
if err := validatePKJWTKeyBinding(finalMethod, keyToStore); err != nil {
return nil, err
}
return &configInitResult{
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise
AuthMethod: finalMethod,
KeyLabel: keyToStore,
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret,
}, nil
}
@@ -402,41 +268,3 @@ func classifyRegistrationError(err error) error {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err)
}
}
// validatePKJWTKeyBinding rejects a registration that resolved to
// private_key_jwt without a signing key bound to it. keyLabel is non-empty only
// when the local flow chose private_key_jwt and signed a TEE attestation; a
// resolved method of private_key_jwt with no key handle would save an unusable
// config (rejected later at config load, surfacing as "saved OK, fails on first
// use"), so it is caught here at registration time instead.
func validatePKJWTKeyBinding(finalMethod, keyLabel string) error {
if finalMethod == core.AuthMethodPrivateKeyJWT && keyLabel == "" {
return errs.NewConfigError(errs.SubtypeInvalidClient,
"registration resolved to private_key_jwt but no signing key was bound to this app (an existing secret-based app may have been selected)").
WithHint("re-register with: lark-cli config init --new --private-key-jwt")
}
return nil
}
// resolveFinalAuthMethod picks the authoritative method from the poll result,
// preferring private_key_jwt, then client_secret. It falls back to the requested
// method when the server returns nothing (older servers).
func resolveFinalAuthMethod(serverMethods []string, requested string) string {
if len(serverMethods) == 0 {
if requested == "" {
return core.AuthMethodClientSecret
}
return requested
}
for _, m := range serverMethods {
if m == core.AuthMethodPrivateKeyJWT {
return core.AuthMethodPrivateKeyJWT
}
}
for _, m := range serverMethods {
if m == core.AuthMethodClientSecret {
return core.AuthMethodClientSecret
}
}
return serverMethods[0]
}

View File

@@ -16,7 +16,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/keysigner"
)
// probeTimeout is the total wall-clock budget for the credential probe step
@@ -91,35 +90,3 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// runProbePKJWT does a best-effort key-binding validation after a private_key_jwt
// config is saved: it signs a client_assertion with the local platform key and
// mints a token. A typed error (a deterministic server rejection — e.g. the key
// is not bound to this app) is propagated so `config init` exits non-zero with
// the canonical envelope; untyped errors (transport / HTTP / parse / timeout)
// are swallowed (return nil). The mint itself is the probe — no second call.
func runProbePKJWT(parent context.Context, factory *cmdutil.Factory, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) error {
if factory == nil {
return nil
}
if signer == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
if _, err := credential.FetchTATWithAssertion(ctx, httpClient, brand, clientID, signer, keyLabel); err != nil {
// Typed = deterministic credential rejection → propagate. Untyped
// (transport / HTTP / parse / timeout) is ambiguous → stay silent.
if errs.IsTyped(err) {
return err
}
return nil
}
return nil
}

View File

@@ -6,11 +6,6 @@ package config
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
crand "crypto/rand"
"crypto/sha256"
"errors"
"io"
"net/http"
@@ -22,17 +17,14 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
oauthHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
oauthCalls int
probeReq *http.Request
probeBody string
}
@@ -56,50 +48,10 @@ func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
case strings.HasSuffix(req.URL.Path, "/authen/v2/oauth/token"):
f.oauthCalls++
if f.oauthHandler == nil {
return jsonResp(200, `{"access_token":"test-token"}`), nil
}
return f.oauthHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
// probeTestSigner is an in-memory real ECDSA P-256 signer used to sign the
// client_assertion in runProbePKJWT tests (authMethodTestSigner returns a nil
// key and cannot sign).
type probeTestSigner struct{ key *ecdsa.PrivateKey }
func newProbeTestSigner(t *testing.T) *probeTestSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
if err != nil {
t.Fatal(err)
}
return &probeTestSigner{key: k}
}
func (p *probeTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(crand.Reader, p.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
@@ -256,12 +208,10 @@ func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
assertSilent(t, err, errBuf)
}
func TestProbeInitResult_ClientSecret(t *testing.T) {
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
opts := &ConfigInitOptions{Ctx: context.Background()}
result := &configInitResult{AppID: "cli_x", AppSecret: "test-secret", Brand: core.BrandFeishu}
err := probeInitResult(opts, f, result)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
@@ -335,47 +285,3 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
// must stay silent and not block.
assertSilent(t, err, errBuf)
}
// runProbePKJWT: a deterministic server rejection (invalid_client) is propagated
// as a typed ConfigError so config init exits non-zero.
func TestRunProbePKJWT_DeterministicReject_Propagates(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"invalid_client","error_description":"unknown key"}`), nil
}}
f, errBuf := fakeFactory(t, rt)
err := runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key")
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected propagated typed error, got %T %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbePKJWT must not write stderr, got %q", errBuf.String())
}
}
// runProbePKJWT: ambiguous upstream noise (HTTP 503) is swallowed — silent, exit 0.
func TestRunProbePKJWT_Ambiguous_Silent(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(503, `unavailable`), nil
}}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
}
// probeInitResult dispatches private_key_jwt to the assertion-backed probe.
func TestProbeInitResult_PrivateKeyJWT(t *testing.T) {
rt := &fakeRT{} // default oauth handler returns 200 + access_token
f, errBuf := fakeFactory(t, rt)
previous := keysigner.Active()
keysigner.Register(newProbeTestSigner(t))
t.Cleanup(func() { keysigner.Register(previous) })
opts := &ConfigInitOptions{Ctx: context.Background()}
result := &configInitResult{AppID: "cli_x", AuthMethod: core.AuthMethodPrivateKeyJWT, KeyLabel: "agent-key", Brand: core.BrandFeishu}
assertSilent(t, probeInitResult(opts, f, result), errBuf)
}
// runProbePKJWT: a nil signer is a defensive no-op (should not be reached, must
// not panic).
func TestRunProbePKJWT_NilSigner_Silent(t *testing.T) {
f, errBuf := fakeFactory(t, &fakeRT{})
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", nil, "k"), errBuf)
}

View File

@@ -10,25 +10,9 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
// TestRunRestoreFlow_NothingToRestore covers the early guards that return before
// any network/registration call: no config at all, and a config whose resolved
// app has no app id (nothing to send on begin).
func TestRunRestoreFlow_NothingToRestore(t *testing.T) {
// No config on disk.
if err := runRestoreFlow(&ConfigInitOptions{}, nil, nil, nil); err == nil {
t.Fatal("expected error when there is no config to restore")
}
// Config present but the resolved app has no app id.
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: ""}}}
if err := runRestoreFlow(&ConfigInitOptions{}, existing, nil, nil); err == nil {
t.Fatal("expected error when the resolved app has no app id")
}
}
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
@@ -135,58 +119,3 @@ func assertValidationParam(t *testing.T, err error, wantParam string) {
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
}
}
// countingKeychain is an in-memory KeychainAccess that records whether Remove
// was invoked, so the stale-secret cleanup can be asserted without a real OS
// keychain.
type countingKeychain struct {
store map[string]string
removeCalled bool
}
func newCountingKeychain() *countingKeychain {
return &countingKeychain{store: map[string]string{}}
}
func (k *countingKeychain) Get(service, account string) (string, error) {
v, ok := k.store[service+"/"+account]
if !ok {
return "", keychain.ErrNotFound
}
return v, nil
}
func (k *countingKeychain) Set(service, account, value string) error {
k.store[service+"/"+account] = value
return nil
}
func (k *countingKeychain) Remove(service, account string) error {
k.removeCalled = true
delete(k.store, service+"/"+account)
return nil
}
func TestRemoveStaleSecretForPKJWT_SameAppID(t *testing.T) {
kc := newCountingKeychain()
ref, err := core.ForStorage("cli_same", core.PlainSecret("old-secret"), kc) // → Source:"keychain"
if err != nil {
t.Fatal(err)
}
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_same", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_same", kc)
if !kc.removeCalled {
t.Error("same appId with keychain secret: expected kc.Remove to be invoked")
}
}
func TestRemoveStaleSecretForPKJWT_DifferentAppID(t *testing.T) {
kc := newCountingKeychain()
ref, _ := core.ForStorage("cli_old", core.PlainSecret("old-secret"), kc)
kc.removeCalled = false // ForStorage does not call Remove, but reset to be safe
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_old", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_new", kc)
if kc.removeCalled {
t.Error("different appId: must NOT remove")
}
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"net/http"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
)
const keylessBindProbeTimeout = 12 * time.Second
var fetchTATForBind = fetchTATForFreshBind
func fetchTATForFreshBind(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, provider, keyRef string) (string, func() error, error) {
helper, commitProviderManifest, err := keylessprovider.PrepareRefresh(ctx, provider)
if err != nil {
return "", nil, err
}
token, err := credential.FetchTATWithAssertionWithHelper(ctx, httpClient, brand, clientID, signer, helper, keyRef)
if err != nil {
return "", nil, err
}
return token, commitProviderManifest, nil
}
// validateBindResult proves that an OpenClaw keyless account can be used by
// the exact helper/keyRef/appID tuple that will be persisted. Minting a TAT is
// intentional: pubkey alone only proves that the helper runs (and some signer
// implementations create a missing key during pubkey); a successful token mint
// proves that this public key is already registered to the selected app, so no
// attach flow or second user authorization is needed.
func validateBindResult(parent context.Context, opts *BindOptions, result *BindResult) error {
if result == nil || result.AppConfig == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "config bind produced no app configuration")
}
app := result.AppConfig
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
return nil
}
if app.KeyRef == nil || app.KeyRef.ID == "" {
return errs.NewConfigError(errs.SubtypeInvalidConfig,
"private_key_jwt bind for app %s is missing keyRef", app.AppId)
}
if strings.TrimSpace(app.KeyRef.Provider) != core.KeylessProviderLarkSuite {
return errs.NewConfigError(errs.SubtypeInvalidClient,
"OpenClaw private_key_jwt bind for app %s did not select provider %s", app.AppId, core.KeylessProviderLarkSuite)
}
if opts == nil || opts.Factory == nil || opts.Factory.HttpClient == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "cannot validate keyless bind without an HTTP client")
}
httpClient, err := opts.Factory.HttpClient()
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
"cannot create HTTP client for keyless bind validation: %v", err).WithCause(err)
}
ctx, cancel := context.WithTimeout(parent, keylessBindProbeTimeout)
defer cancel()
_, commitProviderManifest, err := fetchTATForBind(
ctx, httpClient, app.Brand, app.AppId, keysigner.Active(), app.KeyRef.Provider, app.KeyRef.ID,
)
if err != nil {
if errs.IsTyped(err) {
return err
}
return errs.NewConfigError(errs.SubtypeInvalidClient,
"OpenClaw signer could not authenticate app %s: %v", app.AppId, err).
WithHint("repair or reinstall the OpenClaw Feishu plugin and its platform signer dependency, verify the keyless account, then retry config bind").
WithCause(err)
}
if commitProviderManifest == nil {
return errs.NewInternalError(errs.SubtypeStorage,
"OpenClaw signer validation did not produce a provider manifest commit")
}
result.commitProviderManifest = commitProviderManifest
return nil
}

View File

@@ -1,438 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keysigner"
)
func TestConfigBindRun_OpenClawKeylessWritesProviderWithoutPath(t *testing.T) {
saveWorkspace(t)
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeOpenClawKeylessConfig(t, "cli_keyless", "openclaw-lark")
var gotProvider, gotKeyRef, gotClientID string
var providerCommits int
var providerCommitSawWorkspace bool
replaceBindProbe(t, func(_ context.Context, _ *http.Client, _ core.LarkBrand, clientID string, _ keysigner.Signer, provider, keyRef string) (string, func() error, error) {
gotClientID, gotProvider, gotKeyRef = clientID, provider, keyRef
return "tat-ok", func() error {
providerCommits++
data, err := os.ReadFile(core.GetConfigPath())
if err != nil {
return err
}
providerCommitSawWorkspace = strings.Contains(string(data), "cli_keyless")
return nil
}, nil
})
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err != nil {
t.Fatalf("configBindRun: %v", err)
}
if gotClientID != "cli_keyless" || gotProvider != core.KeylessProviderLarkSuite || gotKeyRef != "openclaw-lark" {
t.Fatalf("probe route = client %q provider %q keyRef %q", gotClientID, gotProvider, gotKeyRef)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
app := multi.CurrentAppConfig("")
if app == nil || app.AuthMethod != core.AuthMethodPrivateKeyJWT || app.KeyRef == nil ||
app.KeyRef.Provider != core.KeylessProviderLarkSuite || app.KeyRef.ID != "openclaw-lark" || !app.AppSecret.IsZero() {
t.Fatalf("persisted app = %#v", app)
}
if stdout.Len() == 0 {
t.Fatal("bind did not emit success envelope")
}
if providerCommits != 1 {
t.Fatalf("provider manifest commits = %d, want 1", providerCommits)
}
if !providerCommitSawWorkspace {
t.Fatal("provider manifest committed before the workspace config became readable")
}
}
func TestConfigBindRun_OpenClawOptionalSignerClosedLoop(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test helper uses a POSIX shebang; Windows resolution is compile-checked separately")
}
saveWorkspace(t)
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
signerPath := installOpenClawOptionalSigner(t)
writeOpenClawKeylessConfig(t, "cli_keyless_optional", "openclaw-lark")
f, _, _, registry := cmdutil.TestFactory(t, nil)
registry.Register(&httpmock.Stub{
Method: http.MethodPost,
URL: auth.PathOAuthTokenV2,
Body: map[string]any{"code": 0, "access_token": "tat-from-optional-signer"},
BodyFilter: func(body []byte) bool {
form, err := url.ParseQuery(string(body))
return err == nil &&
form.Get("client_id") == "cli_keyless_optional" &&
form.Get("client_assertion_type") == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" &&
form.Get("client_assertion") == "optional.jwt" &&
!form.Has("client_secret")
},
})
if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err != nil {
t.Fatalf("configBindRun: %v", err)
}
data, err := os.ReadFile(core.GetConfigPath())
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(string(data), "\n") || !strings.Contains(string(data), "\n \"apps\": [") {
t.Fatalf("config is not formatted JSON with a trailing newline:\n%s", data)
}
if strings.Contains(string(data), signerPath) {
t.Fatalf("config persisted the discovered signer executable path:\n%s", data)
}
providerData, err := os.ReadFile(filepath.Join(core.GetBaseConfigDir(), "signing-providers.json"))
if err != nil {
t.Fatalf("read global signer manifest: %v", err)
}
if !strings.Contains(string(providerData), signerPath) {
t.Fatalf("global signer manifest did not record the verified executable")
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
app := multi.CurrentAppConfig("")
if app == nil || app.AppId != "cli_keyless_optional" || app.KeyRef == nil ||
app.KeyRef.Provider != core.KeylessProviderLarkSuite || app.KeyRef.ID != "openclaw-lark" || !app.AppSecret.IsZero() {
t.Fatalf("resolved config = %#v", app)
}
}
func TestConfigBindRun_OpenClawKeylessProbeFailureDoesNotWrite(t *testing.T) {
saveWorkspace(t)
clearAgentEnv(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
writeOpenClawKeylessConfig(t, "cli_wrong_key", "openclaw-lark")
replaceBindProbe(t, func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error) {
return "", nil, errs.NewConfigError(errs.SubtypeInvalidClient, "public key is not bound")
})
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"}); err == nil {
t.Fatal("expected probe error")
}
if _, err := os.Stat(filepath.Join(base, "openclaw", "config.json")); !os.IsNotExist(err) {
t.Fatalf("config must not be written; stat error = %v", err)
}
}
func TestConfigBindRun_OpenClawKeylessMissingProviderCommitFailsClosed(t *testing.T) {
saveWorkspace(t)
clearAgentEnv(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
writeOpenClawKeylessConfig(t, "cli_missing_commit", "openclaw-lark")
replaceBindProbe(t, func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error) {
return "tat-ok", nil, nil
})
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", Identity: "bot-only"})
if err == nil || !strings.Contains(err.Error(), "did not produce a provider manifest commit") {
t.Fatalf("configBindRun error = %v", err)
}
if _, statErr := os.Stat(filepath.Join(base, "openclaw", "config.json")); !os.IsNotExist(statErr) {
t.Fatalf("config must not be written; stat error = %v", statErr)
}
}
func TestCommitBinding_ProviderManifestFailureRestoresWorkspace(t *testing.T) {
saveWorkspace(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatal(err)
}
previous := []byte("{\n \"current_app\": \"old\",\n \"apps\": [{\"name\": \"old\", \"app_id\": \"cli_old\", \"app_secret\": \"keep\"}]\n}\n")
if err := os.WriteFile(configPath, previous, 0600); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
commitCalls := 0
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
commitCalls++
return errors.New("manifest write failed")
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "workspace config restored") {
t.Fatalf("commitBinding error = %v", err)
}
if commitCalls != 1 {
t.Fatalf("provider manifest commits = %d, want 1", commitCalls)
}
got, readErr := os.ReadFile(configPath)
if readErr != nil {
t.Fatal(readErr)
}
if string(got) != string(previous) {
t.Fatalf("workspace was not restored:\n%s", got)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
func TestCommitBinding_ProviderManifestFailureRemovesNewWorkspace(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
return errors.New("manifest write failed")
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, nil, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "workspace config restored") {
t.Fatalf("commitBinding error = %v", err)
}
if _, statErr := os.Stat(configPath); !os.IsNotExist(statErr) {
t.Fatalf("new workspace config was not removed; stat error = %v", statErr)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
func TestCommitBinding_WorkspaceWriteFailureDoesNotCommitProvider(t *testing.T) {
saveWorkspace(t)
base := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", base)
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
f, _, _, _ := cmdutil.TestFactory(t, nil)
providerCommitted := false
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
providerCommitted = true
return nil
},
}
configPath := filepath.Join(base, "missing-parent", "config.json")
if err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, nil, "openclaw", configPath); err == nil {
t.Fatal("expected workspace write failure")
}
if providerCommitted {
t.Fatal("provider manifest was committed before the workspace config write succeeded")
}
}
func TestCommitBinding_ConcurrentWorkspaceChangeFailsBeforeProviderCommit(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatal(err)
}
previous := []byte(`{"apps":[{"app_id":"cli_old","app_secret":"old"}]}`)
concurrent := []byte(`{"apps":[{"app_id":"cli_other","app_secret":"newer"}]}`)
if err := os.WriteFile(configPath, concurrent, 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
providerCommitted := false
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
providerCommitted = true
return nil
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "changed while the bind was being validated") {
t.Fatalf("commitBinding error = %v", err)
}
if providerCommitted {
t.Fatal("provider manifest was committed after a concurrent workspace change")
}
got, readErr := os.ReadFile(configPath)
if readErr != nil || string(got) != string(concurrent) {
t.Fatalf("concurrent workspace was overwritten: %q, %v", got, readErr)
}
}
func TestCommitBinding_ProviderFailureDoesNotOverwriteConcurrentWriter(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
configPath := core.GetConfigPath()
if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil {
t.Fatal(err)
}
previous := []byte(`{"apps":[{"app_id":"cli_old","app_secret":"old"}]}`)
concurrent := []byte(`{"apps":[{"app_id":"cli_other","app_secret":"newer"}]}`)
if err := os.WriteFile(configPath, previous, 0600); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
result := &BindResult{
AppConfig: &core.AppConfig{AppId: "cli_new", Brand: core.BrandFeishu},
commitProviderManifest: func() error {
if err := os.WriteFile(configPath, concurrent, 0600); err != nil {
return err
}
return errors.New("manifest write failed")
},
}
err := commitBinding(&BindOptions{Factory: f, Identity: "bot-only"}, result, previous, "openclaw", configPath)
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
t.Fatalf("commitBinding error = %v", err)
}
got, readErr := os.ReadFile(configPath)
if readErr != nil || string(got) != string(concurrent) {
t.Fatalf("concurrent workspace was overwritten: %q, %v", got, readErr)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("failed bind emitted success output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
func TestMergeBoundApp_UpsertsAndActivatesWithoutClobberingSiblings(t *testing.T) {
lang := "en_us"
previous := &core.MultiAppConfig{
StrictMode: core.StrictModeUser,
CurrentApp: "other",
Apps: []core.AppConfig{
{Name: "bound", AppId: "cli_target", Brand: core.BrandLark, Lang: coreLang(lang), Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "alice"}}},
{Name: "other", AppId: "cli_other", AppSecret: core.PlainSecret("keep"), Brand: core.BrandFeishu, Users: []core.AppUser{}},
},
}
beforeSibling := previous.Apps[1]
data := mustJSON(t, previous)
incoming := &core.AppConfig{AppId: "cli_target", Brand: core.BrandFeishu, AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyRef: &core.SecretRef{Source: core.SecretSourceTEE, Provider: core.KeylessProviderLarkSuite, ID: "openclaw-lark"}}
got, err := mergeBoundApp(incoming, data, false)
if err != nil {
t.Fatal(err)
}
if len(got.Apps) != 2 || got.CurrentApp != "bound" || got.PreviousApp != "other" || got.StrictMode != previous.StrictMode {
t.Fatalf("merged root = %#v", got)
}
if !reflect.DeepEqual(got.Apps[1], beforeSibling) {
t.Fatalf("sibling changed: got %#v want %#v", got.Apps[1], beforeSibling)
}
if got.Apps[0].Name != "bound" || got.Apps[0].Lang != coreLang(lang) || !reflect.DeepEqual(got.Apps[0].Users, previous.Apps[0].Users) {
t.Fatalf("target-owned fields were lost: %#v", got.Apps[0])
}
}
func writeOpenClawKeylessConfig(t *testing.T, appID, keyRef string) {
t.Helper()
path := filepath.Join(t.TempDir(), "openclaw.json")
data := []byte(`{"channels":{"feishu":{"appId":"` + appID + `","authMethod":"private_key_jwt","keyRef":"` + keyRef + `","domain":"feishu"}}}`)
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
t.Setenv("OPENCLAW_CONFIG_PATH", path)
}
func replaceBindProbe(t *testing.T, fn func(context.Context, *http.Client, core.LarkBrand, string, keysigner.Signer, string, string) (string, func() error, error)) {
t.Helper()
previous := fetchTATForBind
fetchTATForBind = fn
t.Cleanup(func() { fetchTATForBind = previous })
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return data
}
func coreLang(value string) i18n.Lang { return i18n.Lang(value) }
func installOpenClawOptionalSigner(t *testing.T) string {
t.Helper()
// This closure specifically exercises the no-inspect compatibility path;
// keylessprovider tests separately cover authoritative managed-project
// discovery from `openclaw plugins inspect`.
t.Setenv("PATH", "")
type signerPackage struct {
name, npmOS, npmCPU, binary string
}
packages := map[string]signerPackage{
"darwin/arm64": {"@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", "lark-keyless-signer"},
"darwin/amd64": {"@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", "lark-keyless-signer"},
"linux/arm64": {"@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", "lark-keyless-signer"},
"linux/amd64": {"@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", "lark-keyless-signer"},
}
spec, ok := packages[runtime.GOOS+"/"+runtime.GOARCH]
if !ok {
t.Skipf("no optional signer package for %s/%s", runtime.GOOS, runtime.GOARCH)
return ""
}
stateDir := filepath.Join(t.TempDir(), "openclaw state")
packageDir := filepath.Join(
stateDir, "extensions", "openclaw-lark", "node_modules", "@larksuite", strings.TrimPrefix(spec.name, "@larksuite/"),
)
binDir := filepath.Join(packageDir, "bin")
if err := os.MkdirAll(binDir, 0700); err != nil {
t.Fatal(err)
}
packageJSON, err := json.MarshalIndent(map[string]any{
"name": spec.name, "version": "1.2.3", "os": []string{spec.npmOS}, "cpu": []string{spec.npmCPU},
}, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(packageDir, "package.json"), append(packageJSON, '\n'), 0600); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\n" +
"IFS= read -r request\n" +
"printf '%s\\n' '{\"ok\":true,\"client_assertion_type\":\"urn:ietf:params:oauth:client-assertion-type:jwt-bearer\",\"client_assertion\":\"optional.jwt\"}'\n"
signerPath := filepath.Join(binDir, spec.binary)
if err := os.WriteFile(signerPath, []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("OPENCLAW_STATE_DIR", stateDir)
t.Setenv("PATH", "")
return signerPath
}

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"sync"
@@ -20,8 +19,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
@@ -138,9 +135,6 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
// ── 3b. private_key_jwt / TEE signer (local; runs even with --offline) ──
checks = append(checks, teeSignerCheck(opts.Ctx, cfg))
// ── 4 & 5. Endpoint reachability ──
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
@@ -154,73 +148,6 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
return warn(name, id.Message, id.Hint)
}
const teeUnavailableHint = "ensure the device secure hardware is accessible (Linux TPM: add your user to the 'tss' group or run with sufficient privileges)"
// teeSignerCheck reports the private_key_jwt signing backend (TEE/TPM) status.
// The probe is local hardware only (no network), so it runs even with --offline;
// in a build without a TEE signer it short-circuits without touching any
// hardware. It is a hard requirement for private_key_jwt apps and purely
// informational for client_secret apps.
func teeSignerCheck(ctx context.Context, cfg *core.CliConfig) checkResult {
usesPKJWT := cfg != nil && cfg.AuthMethod == core.AuthMethodPrivateKeyJWT
if usesPKJWT && cfg.KeyProvider != "" {
helper, err := keylessprovider.Resolve(ctx, cfg.KeyProvider)
if err != nil {
return fail("tee_signer", "external keyless signer is unavailable",
fmt.Sprintf("repair or reinstall the OpenClaw Feishu plugin and its platform signer dependency: %v", err))
}
keyLabel := ""
if cfg != nil {
keyLabel = cfg.KeyLabel
}
if err := helper.Probe(ctx, keyLabel); err != nil {
hint := fmt.Sprintf("fix the configured external keyless signer, or re-run config init to replace/remove it: %v", err)
if usesPKJWT {
return fail("tee_signer", "external keyless signer is unavailable", hint)
}
return warn("tee_signer", "external keyless signer is misconfigured", hint)
}
return pass("tee_signer", "external keyless signer available")
}
info, ok, err := keysigner.ProbeActiveHardware(ctx)
return teeCheckResult(info, ok, err, usesPKJWT)
}
// teeCheckResult maps a hardware probe to a doctor check. Split out from
// teeSignerCheck so the full matrix is unit-testable without a TPM.
func teeCheckResult(info keysigner.HardwareInfo, ok bool, probeErr error, usesPKJWT bool) checkResult {
const name = "tee_signer"
// No signer registered → private_key_jwt is unsupported on this build.
if !ok {
if usesPKJWT {
return fail(name,
"app uses private_key_jwt but this build has no TEE key signer",
"the platform key signer ships by default on macOS, Linux, and Windows/amd64; this platform (e.g. Windows/arm64) has none — use a supported platform or re-register without --private-key-jwt")
}
return skip(name, "no TEE signer in this build (only private_key_jwt is affected; client_secret is unaffected)")
}
backend := info.Backend
if backend == "" {
backend = "tee"
}
switch {
case probeErr != nil:
return warn(name, fmt.Sprintf("%s signer present but probe errored: %s", backend, probeErr), "")
case info.Available:
if info.VendorName != "" {
return pass(name, fmt.Sprintf("%s TEE available (%s)", backend, info.VendorName))
}
return pass(name, fmt.Sprintf("%s TEE available", backend))
case usesPKJWT:
return fail(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
default:
return warn(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
}
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
@@ -230,8 +157,8 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
}
}
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
// Connectivity checks are platform traffic and must exercise the same
// provider-aware route as real platform requests.
httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp"
@@ -310,90 +237,14 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
}
}
workspace := core.CurrentWorkspace().Display()
// A terminal on STDOUT gets a readable report; pipes, redirects, scripts and
// tests keep the stable JSON contract (NO_COLOR disables ANSI styling).
// OutIsTerminal checks stdout specifically — IOStreams.IsTerminal reflects
// stdin, which would wrongly send the human report into `doctor | jq`.
if f.IOStreams.OutIsTerminal {
renderDoctorHuman(f.IOStreams.Out, workspace, checks, allOK, os.Getenv("NO_COLOR") == "")
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": allOK,
"workspace": workspace,
"checks": checks,
})
result := map[string]interface{}{
"ok": allOK,
"workspace": core.CurrentWorkspace().Display(),
"checks": checks,
}
output.PrintJson(f.IOStreams.Out, result)
if !allOK {
return output.ErrBare(1)
}
return nil
}
// renderDoctorHuman writes a readable health report: one aligned line per check
// with a colored status tag, an indented hint when present, and a summary line.
func renderDoctorHuman(w io.Writer, workspace string, checks []checkResult, allOK, color bool) {
const (
green = "\033[32m"
yellow = "\033[33m"
red = "\033[31m"
gray = "\033[90m"
bold = "\033[1m"
reset = "\033[0m"
)
colorOf := map[string]string{"pass": green, "warn": yellow, "fail": red, "skip": gray}
tagOf := map[string]string{"pass": "PASS", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"}
paint := func(code, s string) string {
if !color || code == "" {
return s
}
return code + s + reset
}
nameW := 0
for _, c := range checks {
if len(c.Name) > nameW {
nameW = len(c.Name)
}
}
fmt.Fprintf(w, "\n%s (workspace: %s)\n\n", paint(bold, "lark-cli doctor"), workspace)
var passN, warnN, failN, skipN int
for _, c := range checks {
tag := tagOf[c.Status]
if tag == "" {
tag = "????"
}
fmt.Fprintf(w, " %s %-*s %s\n", paint(colorOf[c.Status], "["+tag+"]"), nameW, c.Name, c.Message)
if c.Hint != "" {
fmt.Fprintf(w, " %-*s %s\n", nameW, "", paint(gray, "↳ "+c.Hint))
}
switch c.Status {
case "pass":
passN++
case "warn":
warnN++
case "fail":
failN++
case "skip":
skipN++
}
}
headline := paint(green, "healthy")
if !allOK {
headline = paint(red, "problems found")
}
fmt.Fprintf(w, "\n %s — %d passed", headline, passN)
if warnN > 0 {
fmt.Fprintf(w, ", %d warning(s)", warnN)
}
if failN > 0 {
fmt.Fprintf(w, ", %d failed", failN)
}
if skipN > 0 {
fmt.Fprintf(w, ", %d skipped", skipN)
}
fmt.Fprintln(w)
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
@@ -18,7 +17,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/keysigner"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -146,107 +144,6 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
assertCheck(t, got.Checks, "identity_ready", "pass")
}
func TestTeeCheckResult(t *testing.T) {
avail := keysigner.HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
unavail := keysigner.HardwareInfo{Backend: "tpm2", Reason: "open /dev/tpmrm0: permission denied"}
cases := []struct {
name string
info keysigner.HardwareInfo
ok bool
probeErr error
pkjwt bool
want string
}{
{"no signer + private_key_jwt → fail", keysigner.HardwareInfo{}, false, nil, true, "fail"},
{"no signer + client_secret → skip", keysigner.HardwareInfo{}, false, nil, false, "skip"},
{"available + private_key_jwt → pass", avail, true, nil, true, "pass"},
{"available + client_secret → pass", avail, true, nil, false, "pass"},
{"unavailable + private_key_jwt → fail", unavail, true, nil, true, "fail"},
{"unavailable + client_secret → warn", unavail, true, nil, false, "warn"},
{"probe error → warn", keysigner.HardwareInfo{Backend: "tpm2"}, true, errors.New("boom"), true, "warn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := teeCheckResult(tc.info, tc.ok, tc.probeErr, tc.pkjwt)
if got.Name != "tee_signer" {
t.Errorf("name = %q, want tee_signer", got.Name)
}
if got.Status != tc.want {
t.Errorf("status = %q, want %q (msg=%q)", got.Status, tc.want, got.Message)
}
})
}
}
// TestDoctorRun_TeeSignerWired proves the tee_signer check is part of doctorRun.
// It asserts the build-independent invariant (a client_secret app must never
// FAIL on TEE) so the test passes whether or not a signer is compiled in.
func TestDoctorRun_TeeSignerWired(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{
Name: "default", AppId: "test-app",
AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
var c *checkResult
for i := range got.Checks {
if got.Checks[i].Name == "tee_signer" {
c = &got.Checks[i]
}
}
if c == nil {
t.Fatalf("tee_signer check not present in doctor output: %#v", got.Checks)
}
if c.Status == "fail" {
t.Errorf("tee_signer = fail for a client_secret app; want skip/warn/pass (msg=%q)", c.Message)
}
}
func TestRenderDoctorHuman(t *testing.T) {
var buf bytes.Buffer
checks := []checkResult{
pass("cli_version", "1.0.50"),
warn("tee_signer", "tpm2 signer present but TEE unavailable", "add your user to the 'tss' group"),
fail("identity_ready", "no usable identity", "run: lark-cli auth status --verify"),
skip("endpoint_open", "skipped (--offline)"),
}
renderDoctorHuman(&buf, "local", checks, false, false)
out := buf.String()
for _, want := range []string{
"lark-cli doctor", "workspace: local",
"[PASS]", "cli_version", "1.0.50",
"[WARN]", "tee_signer", "↳ add your user to the 'tss' group",
"[FAIL]", "identity_ready", "↳ run: lark-cli auth status --verify",
"[SKIP]", "endpoint_open",
"problems found", "1 passed", "1 warning(s)", "1 failed", "1 skipped",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n---\n%s", want, out)
}
}
if strings.Contains(out, "\033[") {
t.Errorf("color=false but ANSI escapes present:\n%s", out)
}
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {

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

@@ -12,9 +12,18 @@ import (
"net/http"
"testing"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/sidecar"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
type failingBody struct {
err error
@@ -263,3 +272,55 @@ func TestInterceptor_EmptyBody(t *testing.T) {
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
}
}
func TestLegacySidecarProviderStillHandlesForcedExternalRequests(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv(envvars.CliProxyKey, "test-key")
previousProvider := exttransport.GetProvider()
exttransport.Register(&Provider{})
t.Cleanup(func() { exttransport.Register(previousProvider) })
seen := make(chan *http.Request, 2)
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
seen <- req.Clone(req.Context())
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: internaltransport.NewHTTPPolicyRouter(base, base)},
exttransport.RequestClassExternal,
)
withSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/protected", nil)
if err != nil {
t.Fatal(err)
}
withSentinel.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
resp, err := client.Do(withSentinel)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
withoutSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/public", nil)
if err != nil {
t.Fatal(err)
}
resp, err = client.Do(withoutSentinel)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
proxied := <-seen
if proxied.URL.Scheme != "http" || proxied.URL.Host != "127.0.0.1:16384" {
t.Fatalf("sentinel request URL = %s, want sidecar route", proxied.URL)
}
if got := proxied.Header.Get(sidecar.HeaderProxyTarget); got != "https://external.example" {
t.Fatalf("sentinel request proxy target = %q", got)
}
passthrough := <-seen
if got := passthrough.URL.String(); got != "https://external.example/public" {
t.Fatalf("non-sentinel request URL = %q, want unchanged", got)
}
}

View File

@@ -15,6 +15,27 @@ type Provider interface {
ResolveInterceptor(ctx context.Context) Interceptor
}
// RequestClass describes the trust boundary of an outbound HTTP request.
// Platform requests target endpoints owned by the CLI's endpoint resolver;
// external requests target user-provided, pre-signed, CDN, registry, or other
// non-platform URLs. Redirect targets are classified again from each hop's
// logical URL; rewriting a host in an interceptor does not add that host to
// the platform endpoint catalog.
type RequestClass string
const (
RequestClassPlatform RequestClass = "platform"
RequestClassExternal RequestClass = "external"
)
// ScopedProvider optionally limits a Provider to selected request classes.
// Providers that do not implement this interface retain the original
// behavior and apply to every request class.
type ScopedProvider interface {
Provider
SupportsRequestClass(RequestClass) bool
}
// Interceptor defines network-layer customization via a pre/post hook pair.
// The built-in transport chain always executes between PreRoundTrip and the
// returned post function, and cannot be skipped or overridden by the extension.

16
go.mod
View File

@@ -7,8 +7,6 @@ require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
@@ -29,10 +27,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/ebitengine/purego v0.10.1
require (
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
@@ -47,21 +42,12 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/certificate-transparency-go v1.1.8 // indirect
github.com/google/certtostore v1.0.6 // indirect
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
github.com/google/go-attestation v0.5.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/go-tspi v0.3.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -71,12 +57,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.31.0 // indirect
)

37
go.sum
View File

@@ -2,8 +2,6 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -52,42 +50,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c h1:KqlxcP2nuOcMjudCvK0qME2K/aFBDH+xcvYv7HYQaYc=
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c/go.mod h1:QGzNH9ujQ2ZUr/CjDGZGWeDAVStrWNjHeEcjJL96Nuk=
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4 h1:z9oNXvtDZv73Rg8UjFhu+wMtDvGkhLm1NMTwZQ68gOM=
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4/go.mod h1:FEWpPBUpkMwxqAbprURvgWgdwjeGkge5QFDaZBsfRHQ=
github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
github.com/google/certificate-transparency-go v1.1.8 h1:LGYKkgZF7satzgTak9R4yzfJXEeYVAjV6/EAEJOf1to=
github.com/google/certificate-transparency-go v1.1.8/go.mod h1:bV/o8r0TBKRf1X//iiiSgWrvII4d7/8OiA+3vG26gI8=
github.com/google/certtostore v1.0.6 h1:LlCIgyTvDxTlcncMPTSYZGo6lCsiHzO6Dy7ff6ltk/0=
github.com/google/certtostore v1.0.6/go.mod h1:2N0ZPLkGvQWhYvXaiBGq02r71fnSLfq78VKIWQHr1wo=
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae h1:Iy1Ad7L9qPtNAFJad+Ch2kwDXrcwu7QUBR0bfChjnEM=
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae/go.mod h1:DoDv8G58DuLNZF0KysYn0bA/6ZWhmRW3fZE2VnGEH0w=
github.com/google/go-attestation v0.5.1 h1:jqtOrLk5MNdliTKjPbIPrAaRKJaKW+0LIU2n/brJYms=
github.com/google/go-attestation v0.5.1/go.mod h1:KqGatdUhg5kPFkokyzSBDxwSCFyRgIgtRkMp6c3lOBQ=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
github.com/google/go-tpm-tools v0.4.2 h1:iyaCPKt2N5Rd0yz0G8ANa022SgCNZkMpp+db6QELtvI=
github.com/google/go-tpm-tools v0.4.2/go.mod h1:fGUDZu4tw3V4hUVuFHmiYgRd0c58/IXivn9v3Ea/ck4=
github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus=
github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -100,8 +70,6 @@ github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd h1:E3y4CkzAXArgOQAw9gzW0Exe7XQqF4MYH3rCYprAj+Q=
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd/go.mod h1:ayRB9iNq3dqzUb9oW2JkoVQkDBkJ88NJb66OH13CKSk=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -129,8 +97,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -171,8 +137,6 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -190,7 +154,6 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -65,7 +65,6 @@ type AppRegistrationResponse struct {
VerificationUriComplete string
ExpiresIn int
Interval int
RequestedAuthMethod string
}
// AppRegistrationResult is the result of a successful app registration poll.
@@ -73,11 +72,6 @@ type AppRegistrationResult struct {
ClientID string
ClientSecret string
UserInfo *AppRegUserInfo
// AuthMethods is the authoritative auth method(s) the app must use, as
// returned by the registration service after user/admin confirmation. It may
// differ from what the client requested, for example when selecting an
// existing client_secret app. Empty is accepted for compatible older servers.
AuthMethods []string
}
// AppRegUserInfo contains user info returned from app registration.
@@ -91,81 +85,10 @@ func appRegistrationEndpoint(brand core.LarkBrand) string {
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
}
// AppRegistrationInit is the response from the app registration init endpoint.
type AppRegistrationInit struct {
Nonce string
SupportedAuthMethods []string // e.g. ["client_secret", "private_key_jwt"]
}
// AppRegistrationBeginOptions parametrizes the registration begin request.
// A zero value selects the legacy client_secret flow, preserving prior behavior.
type AppRegistrationBeginOptions struct {
AuthMethod string // "" => client_secret; core.AuthMethodPrivateKeyJWT
AuthAttestation string // private_key_jwt: the TEE-signed attestation JWT
RestoreAppID string // when set, asks the server to re-register this existing app
}
// RequestAppRegistrationInit performs the init step of the registration flow,
// returning a server nonce (to be embedded in a TEE-signed attestation JWT) and
// the auth methods the server supports for this archetype.
func RequestAppRegistrationInit(ctx context.Context, httpClient *http.Client) (*AppRegistrationInit, error) {
// Registration always begins against the Feishu accounts host (mirrors begin).
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout)
defer cancel()
form := url.Values{}
form.Set("action", "init")
form.Set("archetype", "PersonalAgent")
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration init failed: read body: %w", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("app registration init failed: HTTP %d response not JSON", resp.StatusCode)
}
if _, hasError := data["error"]; resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("app registration init failed: %s", msg)
}
out := &AppRegistrationInit{
Nonce: getStr(data, "nonce"),
SupportedAuthMethods: parseAuthMethods(data["supported_auth_methods"]),
}
if out.Nonce == "" {
return nil, fmt.Errorf("app registration init failed: server returned no nonce")
}
return out, nil
}
// RequestAppRegistration initiates the device flow. The registration protocol
// always bootstraps on Feishu; brand selects the user-facing verification host.
// The request is bounded by ctx and a begin timeout.
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, opts AppRegistrationBeginOptions, errOut io.Writer) (*AppRegistrationResponse, error) {
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -176,25 +99,11 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
ep := core.ResolveEndpoints(brand)
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
authMethod := opts.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
form := url.Values{}
form.Set("action", "begin")
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", authMethod)
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
if opts.AuthAttestation != "" {
form.Set("auth_attestation", opts.AuthAttestation)
}
// Restore flow: the registration service accepts the existing OAuth client
// identifier under client_id. The launcher URL still uses app_id; these are
// separate contracts and must not be changed together.
if opts.RestoreAppID != "" {
form.Set("client_id", opts.RestoreAppID)
}
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
@@ -247,24 +156,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
// Prefer the server-provided complete URL (currently /page/launcher); fall
// back to building it from verification_uri, then to /page/launcher. The old
// hard-coded /page/cli is stale — the server now returns /page/launcher.
verificationUriComplete := getStr(data, "verification_uri_complete")
if verificationUriComplete == "" {
base := verificationUri
if base == "" {
base = ep.Open + "/page/launcher"
}
// The server may return verification_uri with its own query (e.g.
// app_id when registering against an existing app), so join with
// the same ?/& logic as BuildVerificationURL.
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
verificationUriComplete = base + sep + "user_code=" + url.QueryEscape(userCode)
}
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
return &AppRegistrationResponse{
DeviceCode: deviceCode,
@@ -273,91 +165,18 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
VerificationUriComplete: verificationUriComplete,
ExpiresIn: expiresIn,
Interval: interval,
RequestedAuthMethod: authMethod,
}, nil
}
// parseAuthMethods normalizes the poll response `auth_method` field, which the
// server returns as a JSON array of strings (e.g. ["private_key_jwt"]) — or, on
// some variants, a single space-separated string.
func parseAuthMethods(v interface{}) []string {
switch t := v.(type) {
case []interface{}:
out := make([]string, 0, len(t))
for _, m := range t {
if s, ok := m.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case string:
return strings.Fields(t)
default:
return nil
}
}
func containsAuthMethod(methods []string, target string) bool {
for _, method := range methods {
if method == target {
return true
}
}
return false
}
func registrationResultComplete(result *AppRegistrationResult, requestedAuthMethod string) bool {
if result.ClientID == "" {
return false
}
if result.ClientSecret != "" {
return true
}
if len(result.AuthMethods) > 0 {
return containsAuthMethod(result.AuthMethods, core.AuthMethodPrivateKeyJWT)
}
// Older servers may omit auth_method. In that case only a begin request
// explicitly made as private_key_jwt may complete without a client secret.
return requestedAuthMethod == core.AuthMethodPrivateKeyJWT
}
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
// When targetAppID is non-empty, it is also included so the launcher can lock
// authorization to that existing app.
func BuildVerificationURL(baseURL, cliVersion string, targetAppID ...string) string {
u, err := url.Parse(baseURL)
if err != nil {
return appendVerificationURLFallback(baseURL, cliVersion, targetAppID...)
}
q := u.Query()
if q.Get("lpv") == "" {
q.Set("lpv", cliVersion)
}
if q.Get("ocv") == "" {
q.Set("ocv", cliVersion)
}
if q.Get("from") == "" {
q.Set("from", "cli")
}
if len(targetAppID) > 0 && targetAppID[0] != "" && q.Get("app_id") == "" {
q.Set("app_id", targetAppID[0])
}
u.RawQuery = q.Encode()
return u.String()
}
func appendVerificationURLFallback(baseURL, cliVersion string, targetAppID ...string) string {
func BuildVerificationURL(baseURL, cliVersion string) string {
sep := "&"
if !strings.Contains(baseURL, "?") {
sep = "?"
}
out := baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) +
return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) +
"&ocv=" + url.QueryEscape(cliVersion) +
"&from=cli"
if len(targetAppID) > 0 && targetAppID[0] != "" && !strings.Contains(baseURL, "app_id=") {
out += "&app_id=" + url.QueryEscape(targetAppID[0])
}
return out
}
// pollOnce performs one ctx-bound poll request and decodes the payload.
@@ -454,7 +273,6 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
AuthMethods: parseAuthMethods(data["auth_method"]),
}
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
result.UserInfo = &AppRegUserInfo{
@@ -463,7 +281,7 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
}
}
if registrationResultComplete(result, resp.RequestedAuthMethod) {
if result.ClientID != "" && result.ClientSecret != "" {
// The issuing domain is authoritative; a contradictory final
// tenant report is a protocol violation, not a brand override.
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" &&

View File

@@ -8,8 +8,6 @@ import (
"errors"
"io"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"time"
@@ -32,14 +30,10 @@ func jsonResponse(body string) *http.Response {
func Test_BuildVerificationURL(t *testing.T) {
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
got, err := url.Parse(result)
if err != nil {
t.Fatal(err)
}
convey.Convey("should add ? separator", t, func() {
convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "1.0.0")
convey.So(got.Query().Get("ocv"), convey.ShouldEqual, "1.0.0")
convey.So(got.Query().Get("from"), convey.ShouldEqual, "cli")
convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
convey.So(result, convey.ShouldStartWith, "https://example.com/verify?")
})
})
@@ -53,237 +47,6 @@ func Test_BuildVerificationURL(t *testing.T) {
convey.So(result, convey.ShouldNotContainSubstring, "?lpv=")
})
})
t.Run("指定已有应用时添加app_id", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify?user_code=abc", "2.0.0", "cli_existing")
got, err := url.Parse(result)
if err != nil {
t.Fatal(err)
}
convey.Convey("should include target app_id", t, func() {
convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_existing")
convey.So(got.Query().Get("client_id"), convey.ShouldEqual, "")
convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "2.0.0")
})
})
t.Run("服务端已返回app_id时不覆盖", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify?app_id=cli_server&user_code=abc", "2.0.0", "cli_existing")
got, err := url.Parse(result)
if err != nil {
t.Fatal(err)
}
convey.Convey("should keep server app_id", t, func() {
convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_server")
})
})
}
// captureClient returns an http.Client that records the last request's form body
// and replies with the given JSON payload.
func captureClient(gotBody *url.Values, respJSON string) *http.Client {
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
v, _ := url.ParseQuery(string(b))
*gotBody = v
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
}),
}
}
func TestRequestAppRegistrationInit_ParsesNonceAndMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-123","supported_auth_methods":["client_secret","private_key_jwt"]}`)
out, err := RequestAppRegistrationInit(context.Background(), hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-123" {
t.Errorf("nonce = %q, want n-123", out.Nonce)
}
if len(out.SupportedAuthMethods) != 2 || out.SupportedAuthMethods[1] != "private_key_jwt" {
t.Errorf("methods = %v", out.SupportedAuthMethods)
}
if body.Get("action") != "init" {
t.Errorf("action = %q, want init", body.Get("action"))
}
}
func TestRequestAppRegistrationInit_ErrorOnMissingNonce(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"supported_auth_methods":["client_secret"]}`)
if _, err := RequestAppRegistrationInit(context.Background(), hc); err == nil {
t.Fatal("expected error when server returns no nonce")
}
}
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods covers the older-server
// back-compat path: an empty supported_auth_methods array parses to an empty
// slice, so the init guard in cmd/config/init_interactive.go
// (`len(SupportedAuthMethods) > 0 && !slices.Contains(...)`) stays false and does
// NOT reject the requested private_key_jwt. This aligns with
// resolveFinalAuthMethod(nil/[], private_key_jwt) == private_key_jwt
// (see cmd/config TestResolveFinalAuthMethod).
func TestRequestAppRegistrationInit_EmptySupportedAuthMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-1","supported_auth_methods":[]}`)
out, err := RequestAppRegistrationInit(context.Background(), hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-1" {
t.Errorf("nonce = %q, want n-1", out.Nonce)
}
if len(out.SupportedAuthMethods) != 0 {
t.Errorf("SupportedAuthMethods = %v, want empty", out.SupportedAuthMethods)
}
// Reproduce the init guard expression on the real parsed result: an empty
// slice must NOT reject private_key_jwt.
rejected := len(out.SupportedAuthMethods) > 0 &&
!slices.Contains(out.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT)
if rejected {
t.Error("empty SupportedAuthMethods must allow private_key_jwt (older-server back-compat)")
}
}
const beginRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestAppRegistration_BeginDefaultsToClientSecret(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q", body.Get("action"))
}
if body.Get("auth_method") != "client_secret" {
t.Errorf("auth_method = %q, want client_secret (default)", body.Get("auth_method"))
}
if body.Has("auth_attestation") {
t.Errorf("auth_attestation should be absent for client_secret, got %q", body.Get("auth_attestation"))
}
// Normal (non-restore) begin must NOT carry client_id.
if body.Has("client_id") {
t.Errorf("client_id should be absent when RestoreAppID is empty, got %q", body.Get("client_id"))
}
}
// TestRequestAppRegistration_BeginRestoreAppID verifies the restore flow sends the
// existing app id on begin so the server re-registers that app.
func TestRequestAppRegistration_BeginRestoreAppID(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{RestoreAppID: "cli_restore_me"}
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q, want begin", body.Get("action"))
}
if body.Get("client_id") != "cli_restore_me" {
t.Errorf("client_id = %q, want cli_restore_me", body.Get("client_id"))
}
if body.Has("app_id") {
t.Errorf("begin form app_id must be absent, got %q", body.Get("app_id"))
}
}
func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) {
cases := []struct {
name string
resp string
want string
}{
{
name: "bare verification_uri",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`,
want: "https://example/verify?user_code=uc",
},
{
name: "verification_uri with existing query",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?app_id=cli_x","expires_in":300,"interval":5}`,
want: "https://example/verify?app_id=cli_x&user_code=uc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body url.Values
hc := captureClient(&body, tc.resp)
got, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil)
if err != nil {
t.Fatal(err)
}
if got.VerificationUriComplete != tc.want {
t.Errorf("VerificationUriComplete = %q, want %q", got.VerificationUriComplete, tc.want)
}
})
}
}
func TestParseAuthMethods(t *testing.T) {
if got := parseAuthMethods([]interface{}{"private_key_jwt", "client_secret"}); len(got) != 2 || got[0] != "private_key_jwt" {
t.Errorf("array form = %v", got)
}
if got := parseAuthMethods("client_secret private_key_jwt"); len(got) != 2 || got[1] != "private_key_jwt" {
t.Errorf("string form = %v", got)
}
if got := parseAuthMethods(nil); got != nil {
t.Errorf("nil form = %v, want nil", got)
}
}
func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: "header.claims.sig",
}
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("auth_method") != "private_key_jwt" {
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
}
if body.Get("auth_attestation") != "header.claims.sig" {
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
}
}
func TestRequestAppRegistration_BeginPrivateKeyJWTExistingAppID(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: "header.claims.sig",
RestoreAppID: "cli_existing",
}
if _, err := RequestAppRegistration(context.Background(), hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("auth_method") != "private_key_jwt" {
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
}
if body.Get("auth_attestation") != "header.claims.sig" {
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
}
if body.Get("client_id") != "cli_existing" {
t.Errorf("client_id = %q, want cli_existing", body.Get("client_id"))
}
}
func TestAppRegistrationEndpoint(t *testing.T) {
@@ -317,11 +80,11 @@ func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBran
}
return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, c.brand, AppRegistrationBeginOptions{}, io.Discard)
resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err)
}
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/launcher?") {
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") {
t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost)
}
})
@@ -352,11 +115,11 @@ func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
t.Errorf("unexpected host polled: %s", r.URL.Host)
return jsonResponse(`{}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, AppRegistrationBeginOptions{}, io.Discard)
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration error = %v", err)
}
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/launcher?user_code=TEST-CODE"; got != want {
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want {
t.Errorf("verification URL = %q, want %q", got, want)
}
@@ -456,51 +219,6 @@ func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
}
}
func TestRegisterAppWithDiscovery_KeylessCompletesWithoutSecret(t *testing.T) {
tests := []struct {
name string
response string
requestedAuthMethod string
}{
{
name: "server explicitly returns private_key_jwt",
response: `{"client_id":"cli_keyless","auth_method":["private_key_jwt"]}`,
},
{
name: "older server omits auth_method for a keyless begin",
response: `{"client_id":"cli_keyless"}`,
requestedAuthMethod: core.AuthMethodPrivateKeyJWT,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polls++
return jsonResponse(tt.response), nil
})}
resp := &AppRegistrationResponse{
DeviceCode: "device",
Interval: 0,
ExpiresIn: 5,
RequestedAuthMethod: tt.requestedAuthMethod,
}
result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if polls != 1 {
t.Errorf("polls = %d, want 1", polls)
}
if result.ClientID != "cli_keyless" || result.ClientSecret != "" {
t.Errorf("result = (%q, %q), want (cli_keyless, empty secret)", result.ClientID, result.ClientSecret)
}
})
}
}
// Neither the first poll nor the cross-brand switch waits out the interval
// (a 5s interval would blow the elapsed bound).
func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
@@ -568,7 +286,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err := RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("begin error = %v", err)
}
@@ -577,7 +295,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("legacy begin error = %v", err)
}
@@ -586,7 +304,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("defaults begin error = %v", err)
}
@@ -595,7 +313,7 @@ func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
}
if _, err := RequestAppRegistration(context.Background(),
serve(`{"interval":5}`), core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard); err == nil {
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
t.Error("missing device_code: expected error, got nil")
}
}
@@ -676,7 +394,7 @@ func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
Header: make(http.Header),
}, nil
})}
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, AppRegistrationBeginOptions{}, io.Discard)
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want a context.Canceled cause", err)
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"fmt"
"net/url"
"time"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keylesshelper"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
)
// ClientAuth describes how to authenticate the OAuth client at the token
// endpoint: with a client_secret (default) or a TEE-signed client_assertion
// (private_key_jwt).
type ClientAuth struct {
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
Signer keysigner.Signer
KeyLabel string
KeyProvider string
// externalSigner is a verified provider snapshot prepared once for a
// multi-request operation (for example a device-flow poll loop). The helper
// still re-verifies its binary and mints a fresh assertion on every call.
externalSigner clientAssertionSigner
}
type clientAssertionSigner interface {
SignClientAssertion(context.Context, string, string, string) (string, string, error)
}
var resolveExternalAssertionSigner = func(ctx context.Context, provider string) (clientAssertionSigner, error) {
return keylessprovider.Resolve(ctx, provider)
}
// ClientAuthFromConfig builds a ClientAuth from resolved config, picking up the
// active key signer for private_key_jwt apps.
func ClientAuthFromConfig(cfg *core.CliConfig) ClientAuth {
if cfg == nil {
return ClientAuth{}
}
return ClientAuth{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
KeyProvider: cfg.KeyProvider,
Signer: keysigner.Active(),
}
}
func (c ClientAuth) isPrivateKeyJWT() bool { return c.AuthMethod == core.AuthMethodPrivateKeyJWT }
// ResolveSigner prepares the external private_key_jwt signer for reuse within
// one operation and returns the prepared copy. Built-in signers and
// client_secret authentication need no provider discovery. Keeping the
// resolved helper on ClientAuth separates expensive provider discovery from
// assertion minting: callers may reuse the returned value, while every call to
// applyClientAssertion still asks the signer for a fresh assertion.
func (c ClientAuth) ResolveSigner(ctx context.Context) (ClientAuth, error) {
if !c.isPrivateKeyJWT() || c.KeyProvider == "" || c.externalSigner != nil {
return c, nil
}
helper, err := resolveExternalAssertionSigner(ctx, c.KeyProvider)
if err != nil {
return c, err
}
if helper == nil {
return c, fmt.Errorf("private_key_jwt provider %q resolved without a signer", c.KeyProvider)
}
c.externalSigner = helper
return c, nil
}
// SignClientAssertion signs with a resolved external helper when present,
// otherwise with the platform signer.
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, helper *keylesshelper.Command, keyLabel, clientID, audience string) (string, string, error) {
if helper != nil {
return helper.SignClientAssertion(ctx, keyLabel, clientID, audience)
}
assertion, err := jwt.SignClientAssertion(ctx, signer, keysigner.KeyRef{Label: keyLabel}, clientID, audience, time.Now())
return jwt.ClientAssertionType, assertion, err
}
// applyClientAssertion adds client_assertion(+type) to a token-endpoint form for
// private_key_jwt and returns true. For client_secret it returns false, leaving
// the caller to apply its own secret-based authentication. audience is the token
// endpoint URL (the assertion's aud claim).
func (c ClientAuth) applyClientAssertion(ctx context.Context, form url.Values, audience string) (bool, error) {
if !c.isPrivateKeyJWT() {
return false, nil
}
var err error
if c.KeyProvider != "" {
c, err = c.ResolveSigner(ctx)
if err != nil {
return false, err
}
}
helper := c.externalSigner
if helper == nil && c.Signer == nil {
return false, fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
}
var assertionType, assertion string
if helper != nil {
assertionType, assertion, err = helper.SignClientAssertion(ctx, c.KeyLabel, c.AppID, audience)
} else {
assertionType, assertion, err = SignClientAssertion(ctx, c.Signer, nil, c.KeyLabel, c.AppID, audience)
}
if err != nil {
return false, err
}
form.Set("client_assertion_type", assertionType)
form.Set("client_assertion", assertion)
return true, nil
}

View File

@@ -1,227 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"fmt"
"net/url"
"testing"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeAuthSigner is a real in-memory ECDSA P-256 signer for client-auth tests.
type fakeAuthSigner struct{ key *ecdsa.PrivateKey }
type fakeExternalAssertionSigner struct {
keyRef, clientID, audience string
calls int
}
func (f *fakeExternalAssertionSigner) SignClientAssertion(_ context.Context, keyRef, clientID, audience string) (string, string, error) {
f.keyRef, f.clientID, f.audience = keyRef, clientID, audience
f.calls++
return jwt.ClientAssertionType, fmt.Sprintf("external.jwt.%d", f.calls), nil
}
func newFakeAuthSigner(t *testing.T) *fakeAuthSigner {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeAuthSigner{key: k}
}
func (f *fakeAuthSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestClientAuth_applyClientAssertion_ClientSecret(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AppSecret: "test-secret"} // AuthMethod "" => client_secret
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://aud/token")
if err != nil {
t.Fatal(err)
}
if used {
t.Error("client_secret must not produce a client_assertion")
}
if form.Has("client_assertion") || form.Has("client_assertion_type") {
t.Errorf("form should be untouched, got %v", form)
}
}
func TestClientAuth_applyClientAssertion_PrivateKeyJWT(t *testing.T) {
ca := ClientAuth{
AppID: "cli_a",
AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t),
KeyLabel: "k",
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token")
if err != nil {
t.Fatal(err)
}
if !used {
t.Fatal("expected client_assertion to be applied")
}
if form.Get("client_assertion_type") != jwt.ClientAssertionType {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be present for private_key_jwt")
}
}
func TestClientAuth_applyClientAssertion_NilSigner(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT} // Signer nil
if _, err := ca.applyClientAssertion(context.Background(), url.Values{}, "aud"); err == nil {
t.Fatal("expected error when private_key_jwt has no signer")
}
}
func TestClientAuth_applyClientAssertion_UnknownProviderFailsClosed(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k", KeyProvider: "evil.provider"}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "aud")
if err == nil || used || form.Has("client_assertion") {
t.Fatalf("unknown provider must fail closed: used=%v form=%v err=%v", used, form, err)
}
}
func TestClientAuth_applyClientAssertion_NilExternalProviderDoesNotFallback(t *testing.T) {
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(context.Context, string) (clientAssertionSigner, error) {
return nil, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
ca := ClientAuth{
AppID: "cli_a", AppSecret: "must-not-send", AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t), KeyLabel: "k", KeyProvider: core.KeylessProviderLarkSuite,
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "aud")
if err == nil || used || form.Has("client_assertion") || form.Has("client_secret") {
t.Fatalf("nil external provider must fail closed: used=%v form=%v err=%v", used, form, err)
}
}
func TestClientAuth_applyClientAssertion_ExplicitProviderDoesNotUseBuiltinOrSecret(t *testing.T) {
fake := &fakeExternalAssertionSigner{}
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) {
if provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q", provider)
}
return fake, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
ca := ClientAuth{
AppID: "cli_external", AppSecret: "must-not-send", AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t), KeyLabel: "openclaw-lark", KeyProvider: core.KeylessProviderLarkSuite,
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "open.feishu.cn")
if err != nil || !used {
t.Fatalf("applyClientAssertion = used %v err %v", used, err)
}
if form.Get("client_assertion") != "external.jwt.1" || form.Has("client_secret") ||
fake.keyRef != "openclaw-lark" || fake.clientID != "cli_external" || fake.audience != "open.feishu.cn" {
t.Fatalf("form=%v signer=(%q,%q,%q)", form, fake.keyRef, fake.clientID, fake.audience)
}
}
func TestClientAuth_ResolveSignerPreparedCopyReusesResolutionAndRemintsAssertions(t *testing.T) {
fake := &fakeExternalAssertionSigner{}
resolveCalls := 0
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) {
resolveCalls++
if provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q", provider)
}
return fake, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
original := ClientAuth{
AppID: "cli_external", AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "openclaw-lark", KeyProvider: core.KeylessProviderLarkSuite,
}
prepared, err := original.ResolveSigner(context.Background())
if err != nil {
t.Fatal(err)
}
if original.externalSigner != nil {
t.Fatal("ResolveSigner must return a prepared copy without mutating the original")
}
forms := []url.Values{{}, {}}
for _, form := range forms {
used, err := prepared.applyClientAssertion(context.Background(), form, "open.feishu.cn")
if err != nil || !used {
t.Fatalf("applyClientAssertion = used %v err %v", used, err)
}
}
if resolveCalls != 1 {
t.Fatalf("provider resolution calls = %d, want 1", resolveCalls)
}
if fake.calls != 2 {
t.Fatalf("assertion signing calls = %d, want 2", fake.calls)
}
first := forms[0].Get("client_assertion")
second := forms[1].Get("client_assertion")
if first == "" || second == "" || first == second {
t.Fatalf("assertions = (%q, %q), want two fresh values", first, second)
}
for _, form := range forms {
if form.Has("client_secret") {
t.Fatalf("private_key_jwt form leaked client_secret: %v", form)
}
}
}
func TestClientAuthFromConfig(t *testing.T) {
ca := ClientAuthFromConfig(&core.CliConfig{
AppID: "cli_x",
AppSecret: "test-secret",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "label-1",
})
if ca.AppID != "cli_x" || ca.AppSecret != "test-secret" || ca.AuthMethod != core.AuthMethodPrivateKeyJWT || ca.KeyLabel != "label-1" {
t.Errorf("ClientAuth = %+v", ca)
}
}

View File

@@ -62,7 +62,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
}
// RequestDeviceAuthorization requests a device authorization code.
func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -77,26 +77,18 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
}
}
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
form := url.Values{}
form.Set("client_id", ca.AppID)
form.Set("client_id", appId)
form.Set("scope", scope)
// private_key_jwt authenticates the client with a signed assertion in the
// body; client_secret uses HTTP Basic.
usedAssertion, err := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if !usedAssertion {
basicAuth := base64.StdEncoding.EncodeToString([]byte(ca.AppID + ":" + ca.AppSecret))
req.Header.Set("Authorization", "Basic "+basicAuth)
}
req.Header.Set("Authorization", "Basic "+basicAuth)
resp, err := httpClient.Do(req)
if err != nil {
@@ -147,7 +139,7 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
}
// PollDeviceToken polls the token endpoint until authorization completes or times out.
func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
if errOut == nil {
errOut = io.Discard
}
@@ -179,16 +171,10 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("device_code", deviceCode)
form.Set("client_id", ca.AppID)
usedAssertion, caErr := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if caErr != nil {
return &DeviceFlowResult{OK: false, Error: "invalid_client", Message: caErr.Error()}
}
if !usedAssertion {
form.Set("client_secret", ca.AppSecret)
}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.Token, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
continue
}

View File

@@ -7,10 +7,8 @@ import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -85,7 +83,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
})
t.Cleanup(restore)
_, err := RequestDeviceAuthorization(context.Background(), httpmock.NewClient(reg), ClientAuth{AppID: "cli_a", AppSecret: "test-secret"}, core.BrandFeishu, "", nil)
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
if err != nil {
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
}
@@ -108,66 +106,6 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
}
}
// captureRT records the last request + body and returns a canned device-auth response.
func captureDeviceAuthClient(gotReq **http.Request, gotBody *string, respJSON string) *http.Client {
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
*gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
*gotBody = string(b)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
})}
}
const deviceAuthRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestDeviceAuthorization_PrivateKeyJWT_UsesAssertionNotBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k"}
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "im:message:send", nil); err != nil {
t.Fatal(err)
}
if req.Header.Get("Authorization") != "" {
t.Errorf("private_key_jwt must NOT send Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Get("client_assertion") == "" {
t.Error("missing client_assertion")
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Has("client_secret") {
t.Error("client_secret must not be present for private_key_jwt")
}
}
func TestRequestDeviceAuthorization_ClientSecret_UsesBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AppSecret: "test-secret"} // client_secret
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "", nil); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") {
t.Errorf("client_secret should use Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Has("client_assertion") {
t.Error("client_secret must not send a client_assertion")
}
}
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
got := keychain.FormatAuthCmdline([]string{
@@ -267,7 +205,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, ClientAuth{AppID: "cli_a", AppSecret: "test-secret"}, core.BrandFeishu, "device-code", 0, 10, nil)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}

View File

@@ -1,152 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package jwt builds compact JWS tokens signed by a keysigner.Signer.
//
// It deliberately depends only on the standard library plus the existing
// google/uuid dependency — no third-party JWT library is introduced, keeping
// go.mod free of new dependencies. The actual signing (and, for ECDSA, the
// ASN.1->r||s conversion) is delegated to the Signer implementation.
package jwt
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/keysigner"
)
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// buildSignedJWT builds a compact JWS:
//
// base64url(header).base64url(claims).base64url(signature)
//
// alg is written into the header (it is part of the signed input) and verified
// against the alg the signer reports, guarding against a header/key mismatch.
// typ defaults to "JWT" because the client-assertion endpoint requires that
// protected-header value, even though some protocol examples show only alg.
func buildSignedJWT(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, alg string, header, claims map[string]any) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
if header == nil {
header = map[string]any{}
}
header["alg"] = alg
if _, ok := header["typ"]; !ok {
header["typ"] = "JWT"
}
hb, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("jwt: marshal header: %w", err)
}
cb, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("jwt: marshal claims: %w", err)
}
signingInput := b64(hb) + "." + b64(cb)
sig, gotAlg, err := signer.Sign(ctx, ref, []byte(signingInput))
if err != nil {
return "", fmt.Errorf("jwt: sign: %w", err)
}
if gotAlg != alg {
return "", fmt.Errorf("jwt: signer alg %q does not match header alg %q", gotAlg, alg)
}
return signingInput + "." + b64(sig), nil
}
// newJTI returns a random unique token identifier.
func newJTI() string { return uuid.NewString() }
// attestationTTL bounds the attestation JWT's lifetime. The init nonce (60s,
// single-use) is the real anti-replay constraint; this is a modest margin for
// clock skew on top of the immediate init→sign→begin round-trip.
const attestationTTL = 2 * time.Minute
// attestationClaims builds the registration attestation claim set per the App
// Registration JWT spec: jti, iat, exp (all required) and the init-issued nonce.
func attestationClaims(nonce string, now time.Time) map[string]any {
return map[string]any{
"jti": newJTI(),
"iat": now.Unix(),
"exp": now.Add(attestationTTL).Unix(),
"nonce": nonce,
}
}
// clientAssertionClaims builds an RFC 7523 client_assertion claim set used to
// mint tokens in place of client_secret. aud is the brand's token endpoint URL.
func clientAssertionClaims(clientID, aud string, now time.Time, ttl time.Duration) map[string]any {
return map[string]any{
"iss": clientID,
"sub": clientID,
"aud": aud,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
"jti": newJTI(),
}
}
// ClientAssertionType is the RFC 7523 client_assertion_type value used for JWT
// bearer client authentication at the token endpoint.
const ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
// defaultAssertionTTL bounds a client_assertion's lifetime.
const defaultAssertionTTL = 5 * time.Minute
// SignAttestation signs the registration attestation JWT. The public key is
// embedded in the JWS "jwk" header so the registration backend can bind it to
// the app during action=begin; the claims carry the server nonce as a
// proof-of-possession challenge.
func SignAttestation(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, nonce string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.EnsureKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: ensure key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
jwk, err := keysigner.PublicKeyJWK(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{"jwk": jwk}, attestationClaims(nonce, now))
}
// SignClientAssertion mints a short-lived RFC 7523 client_assertion: it reads the
// registered key (it must already exist — bound at registration; a missing key is
// an error, not a reason to create a new unbound one), derives the JWS alg from
// the public key, and signs an assertion whose audience is the brand's Open API
// host. The server, holding the public key bound at registration, verifies it in
// place of client_secret. The assertion header carries only alg (no jwk/kid);
// the server locates the key via iss/sub = client_id.
//
// This is the model-independent glue: the assertion JWT is identical whether the
// server augments an existing grant (device_code/refresh_token) with client
// authentication or uses a dedicated jwt-bearer grant — only where the caller
// attaches it differs.
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, clientID, audience string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.PublicKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: public key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{}, clientAssertionClaims(clientID, audience, now, defaultAssertionTTL))
}

View File

@@ -1,254 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package jwt
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeSigner is a real in-memory ECDSA P-256 signer, so tests exercise the full
// JWS path and the produced token is actually cryptographically verifiable.
type fakeSigner struct{ key *ecdsa.PrivateKey }
func newFakeSigner(t *testing.T) *fakeSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeSigner{key: k}
}
func (f *fakeSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
// JOSE ES256: fixed-width big-endian r||s (32 bytes each for P-256).
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestBuildSignedJWT_VerifiableES256(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{Label: "x"}, keysigner.AlgES256,
map[string]any{}, clientAssertionClaims("cli_app", "https://accounts.example/token", now, 5*time.Minute))
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 JWS parts, got %d", len(parts))
}
hb, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("header not base64url: %v", err)
}
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
if hdr["alg"] != "ES256" || hdr["typ"] != "JWT" {
t.Errorf("header = %v, want alg=ES256 typ=JWT", hdr)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" || claims["aud"] != "https://accounts.example/token" {
t.Errorf("claims = %v", claims)
}
// Cryptographically verify the signature against the signing input.
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatalf("sig not base64url: %v", err)
}
if len(sig) != 64 {
t.Fatalf("ES256 sig len = %d, want 64", len(sig))
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("signature did not verify")
}
}
func TestBuildSignedJWT_NilSigner(t *testing.T) {
if _, err := buildSignedJWT(context.Background(), nil, keysigner.KeyRef{}, "ES256", nil, nil); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestBuildSignedJWT_AlgMismatch(t *testing.T) {
f := newFakeSigner(t) // always reports ES256
if _, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{}, keysigner.AlgRS256, nil, nil); err == nil {
t.Fatal("expected error when header alg != signer alg")
}
}
func TestBuildSignedJWT_MarshalErrors(t *testing.T) {
f := newFakeSigner(t)
ctx := context.Background()
_, err := buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
map[string]any{"bad": func() {}}, nil)
if err == nil || !strings.Contains(err.Error(), "jwt: marshal header") {
t.Fatalf("header marshal error = %v, want prefix %q", err, "jwt: marshal header")
}
_, err = buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
nil, map[string]any{"bad": make(chan int)})
if err == nil || !strings.Contains(err.Error(), "jwt: marshal claims") {
t.Fatalf("claims marshal error = %v, want prefix %q", err, "jwt: marshal claims")
}
}
func TestSignClientAssertion(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
const aud = "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token"
tok, err := SignClientAssertion(context.Background(), f, keysigner.KeyRef{Label: "k"}, "cli_app", aud, now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["aud"] != aud {
t.Errorf("claims = %v", claims)
}
// Signature must verify against the key's public half.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("client_assertion signature did not verify")
}
}
func TestSignClientAssertion_NilSigner(t *testing.T) {
if _, err := SignClientAssertion(context.Background(), nil, keysigner.KeyRef{}, "cli_app", "aud", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestSignAttestation(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := SignAttestation(context.Background(), f, keysigner.KeyRef{Label: "k"}, "nonce-abc", now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
jwk, ok := hdr["jwk"].(map[string]any)
if !ok {
t.Fatalf("attestation header missing jwk: %v", hdr)
}
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["use"] != "sig" {
t.Errorf("jwk = %v", jwk)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["nonce"] != "nonce-abc" {
t.Errorf("nonce = %v", claims["nonce"])
}
// jti, iat, exp are all required by the attestation spec.
iat, iatOK := claims["iat"].(float64)
exp, expOK := claims["exp"].(float64)
if !iatOK || !expOK || exp <= iat {
t.Errorf("claims iat/exp invalid: iat=%v exp=%v", claims["iat"], claims["exp"])
}
if jti, _ := claims["jti"].(string); jti == "" {
t.Error("claims jti empty")
}
// Signature verifies against the embedded key.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("attestation signature did not verify")
}
}
func TestSignAttestation_NilSigner(t *testing.T) {
if _, err := SignAttestation(context.Background(), nil, keysigner.KeyRef{}, "n", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestClaimFactories(t *testing.T) {
now := time.Unix(1700000000, 0)
a := attestationClaims("nonce-xyz", now)
if a["nonce"] != "nonce-xyz" || a["iat"] != now.Unix() {
t.Errorf("attestation claims = %v", a)
}
if a["exp"] != now.Add(attestationTTL).Unix() {
t.Errorf("attestation exp = %v, want %v", a["exp"], now.Add(attestationTTL).Unix())
}
if jti, _ := a["jti"].(string); jti == "" {
t.Error("attestation jti empty")
}
c := clientAssertionClaims("cli_app", "aud", now, time.Minute)
if c["exp"].(int64) != now.Add(time.Minute).Unix() {
t.Errorf("client_assertion exp = %v", c["exp"])
}
}

View File

@@ -17,6 +17,8 @@ import (
"github.com/larksuite/cli/internal/transport"
)
var _ transport.RoundTripperDecorator = (*SecurityPolicyTransport)(nil)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
// and checks for security policy errors.
type SecurityPolicyTransport struct {
@@ -31,6 +33,16 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *SecurityPolicyTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *SecurityPolicyTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base().RoundTrip(req)

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/vfs"
)
@@ -34,15 +33,11 @@ func sanitizeID(id string) string {
// UATCallOptions contains options for UAT API calls.
type UATCallOptions struct {
UserOpenId string
AppId string
AppSecret string
Domain core.LarkBrand
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle for private_key_jwt
KeyProvider string // empty == built-in signer; explicit external route otherwise
Signer keysigner.Signer // active signer for private_key_jwt
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
UserOpenId string
AppId string
AppSecret string
Domain core.LarkBrand
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
}
// UATStatus represents the status of a user access token.
@@ -62,15 +57,11 @@ func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
errOut = os.Stderr
}
return UATCallOptions{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
KeyProvider: cfg.KeyProvider,
Signer: keysigner.Active(),
ErrOut: errOut,
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
ErrOut: errOut,
}
}
@@ -196,31 +187,13 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
clientAuth := ClientAuth{
AppID: opts.AppId,
AppSecret: opts.AppSecret,
AuthMethod: opts.AuthMethod,
Signer: opts.Signer,
KeyLabel: opts.KeyLabel,
KeyProvider: opts.KeyProvider,
}
clientAuth, err := clientAuth.ResolveSigner(context.Background())
if err != nil {
return nil, err
}
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
usedAssertion, caErr := clientAuth.applyClientAssertion(context.Background(), form, core.OpenAPIAudience(opts.Domain))
if caErr != nil {
return nil, caErr
}
if !usedAssertion {
form.Set("client_secret", opts.AppSecret)
}
form.Set("client_secret", opts.AppSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {

View File

@@ -38,27 +38,3 @@ func TestNewUATCallOptions(t *testing.T) {
t.Error("ErrOut not set correctly")
}
}
// TestNewUATCallOptions_PrivateKeyJWT verifies the auth-method fields propagate
// so the refresh path can mint a client_assertion instead of sending a secret.
func TestNewUATCallOptions_PrivateKeyJWT(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_pk",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "agent-key",
KeyProvider: core.KeylessProviderLarkSuite,
}
opts := NewUATCallOptions(cfg, &bytes.Buffer{})
if opts.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Errorf("AuthMethod = %q, want private_key_jwt", opts.AuthMethod)
}
if opts.KeyLabel != "agent-key" {
t.Errorf("KeyLabel = %q, want agent-key", opts.KeyLabel)
}
if opts.KeyProvider != core.KeylessProviderLarkSuite {
t.Errorf("KeyProvider = %q, want %q", opts.KeyProvider, core.KeylessProviderLarkSuite)
}
}

View File

@@ -1,122 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
)
type uatRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn uatRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type retryExternalAssertionSigner struct {
calls int
}
func (s *retryExternalAssertionSigner) SignClientAssertion(_ context.Context, _, _, _ string) (string, string, error) {
s.calls++
return jwt.ClientAssertionType, fmt.Sprintf("refresh.jwt.%d", s.calls), nil
}
func TestDoRefreshToken_PrivateKeyJWTRetryResolvesOnceAndRemintsAssertion(t *testing.T) {
signer := &retryExternalAssertionSigner{}
resolveCalls := 0
previous := resolveExternalAssertionSigner
resolveExternalAssertionSigner = func(_ context.Context, provider string) (clientAssertionSigner, error) {
resolveCalls++
if provider != core.KeylessProviderLarkSuite {
t.Fatalf("provider = %q", provider)
}
return signer, nil
}
t.Cleanup(func() { resolveExternalAssertionSigner = previous })
var forms []url.Values
httpClient := &http.Client{Transport: uatRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
form, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
forms = append(forms, form)
responseBody := `{"code":20050,"error":"server_error","error_description":"retry"}`
if len(forms) == 2 {
// A success-shaped response without a token lets the test exercise the
// retry without writing platform keychain state.
responseBody = `{"code":0}`
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(responseBody)),
Request: req,
}, nil
})}
now := time.Now().UnixMilli()
stored := &StoredUAToken{
UserOpenId: "ou_test",
AppId: "cli_external",
RefreshToken: "refresh-token",
RefreshExpiresAt: now + int64(time.Hour/time.Millisecond),
Scope: "offline_access",
GrantedAt: now,
}
opts := UATCallOptions{
UserOpenId: stored.UserOpenId,
AppId: stored.AppId,
Domain: core.BrandFeishu,
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "openclaw-lark",
KeyProvider: core.KeylessProviderLarkSuite,
ErrOut: io.Discard,
}
updated, err := doRefreshToken(httpClient, opts, stored)
if err == nil || !strings.Contains(err.Error(), "no access_token") {
t.Fatalf("doRefreshToken error = %v, want missing access_token after retry", err)
}
if updated != nil {
t.Fatalf("updated token = %#v, want nil", updated)
}
if resolveCalls != 1 {
t.Fatalf("provider resolution calls = %d, want 1", resolveCalls)
}
if signer.calls != 2 {
t.Fatalf("assertion signing calls = %d, want 2", signer.calls)
}
if len(forms) != 2 {
t.Fatalf("token endpoint requests = %d, want 2", len(forms))
}
first := forms[0].Get("client_assertion")
second := forms[1].Get("client_assertion")
if first == "" || second == "" || first == second {
t.Fatalf("assertions = (%q, %q), want two fresh values", first, second)
}
for _, form := range forms {
if form.Get("grant_type") != "refresh_token" {
t.Fatalf("grant_type = %q, want refresh_token", form.Get("grant_type"))
}
if form.Has("client_secret") {
t.Fatalf("private_key_jwt form leaked client_secret: %v", form)
}
}
}

View File

@@ -1,112 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import "testing"
func TestListCandidateApps_KeylessSingleAccount(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_keyless",
Brand: "feishu",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
})
if len(apps) != 1 {
t.Fatalf("count = %d, want 1", len(apps))
}
if !apps[0].IsKeyless() {
t.Fatalf("candidate = %#v, want keyless", apps[0])
}
if apps[0].KeyRef != "openclaw-lark" {
t.Fatalf("KeyRef = %q, want openclaw-lark", apps[0].KeyRef)
}
}
func TestListCandidateApps_KeylessMultiAccountInheritance(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_top",
Brand: "lark",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
Accounts: map[string]*FeishuAccount{
"work": {},
},
})
if len(apps) != 2 {
t.Fatalf("count = %d, want 2 (implicit default + work)", len(apps))
}
app := candidateByLabel(t, apps, "work")
if app.Label != "work" || app.AppID != "cli_top" || app.Brand != "lark" {
t.Fatalf("candidate identity = %#v", app)
}
if app.AuthMethod != AuthMethodPrivateKeyJWT || app.KeyRef != "openclaw-lark" || !app.IsKeyless() {
t.Fatalf("candidate keyless fields = %#v", app)
}
}
func TestListCandidateApps_KeylessAccountOverride(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_top",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "top-key",
Accounts: map[string]*FeishuAccount{
"work": {AppID: "cli_work", KeyRef: "work-key"},
},
})
if len(apps) != 2 {
t.Fatalf("count = %d, want 2 (implicit default + work)", len(apps))
}
if got := candidateByLabel(t, apps, "work"); got.AppID != "cli_work" || got.KeyRef != "work-key" || !got.IsKeyless() {
t.Fatalf("candidate = %#v", got)
}
}
func candidateByLabel(t *testing.T, apps []CandidateApp, label string) CandidateApp {
t.Helper()
for _, app := range apps {
if app.Label == label {
return app
}
}
t.Fatalf("candidate %q not found in %#v", label, apps)
return CandidateApp{}
}
func TestCandidateApp_SecretTakesPrecedenceOverKeyless(t *testing.T) {
app := CandidateApp{
AppID: "cli_both",
AppSecret: SecretInput{Plain: "secret"},
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
}
if app.IsKeyless() {
t.Fatal("an appSecret-backed OpenClaw account must not be treated as keyless")
}
}
func TestCandidateApp_KeylessRequiresKeyRef(t *testing.T) {
app := CandidateApp{AppID: "cli_missing_key", AuthMethod: AuthMethodPrivateKeyJWT}
if app.IsKeyless() {
t.Fatal("private_key_jwt without keyRef must not be treated as usable keyless")
}
}
func TestListCandidateApps_KeylessImplicitDefault(t *testing.T) {
apps := ListCandidateApps(&FeishuChannel{
AppID: "cli_default",
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: "openclaw-lark",
Accounts: map[string]*FeishuAccount{
"work": {AppID: "cli_work", AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: "work-key"},
},
})
if len(apps) != 2 {
t.Fatalf("count = %d, want 2", len(apps))
}
for _, app := range apps {
if !app.IsKeyless() {
t.Fatalf("candidate = %#v, want keyless", app)
}
}
}

View File

@@ -31,24 +31,20 @@ type ChannelsRoot struct {
// `Brand` stays aligned with our internal terminology, but the JSON
// tag matches OpenClaw's on-disk format.
type FeishuChannel struct {
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
Accounts map[string]*FeishuAccount `json:"accounts,omitempty"`
AuthMethod string `json:"authMethod,omitempty"`
KeyRef string `json:"keyRef,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
Accounts map[string]*FeishuAccount `json:"accounts,omitempty"`
}
// FeishuAccount is a single account entry within Accounts.
// Like FeishuChannel, `Brand` maps to OpenClaw's `domain` key.
type FeishuAccount struct {
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
AuthMethod string `json:"authMethod,omitempty"`
KeyRef string `json:"keyRef,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
}
// isEnabled returns true if the enabled field is nil (default) or explicitly true.
@@ -232,25 +228,10 @@ func LookupProvider(ref *SecretRef, cfg *SecretsConfig) (*ProviderConfig, error)
// CandidateApp represents a bindable app from OpenClaw's feishu channel config.
type CandidateApp struct {
Label string
AppID string
AppSecret SecretInput
Brand string
AuthMethod string
KeyRef string
}
const AuthMethodPrivateKeyJWT = "private_key_jwt"
// IsKeyless mirrors openclaw-lark's resolved-account precedence: an app
// secret wins when both credential shapes are present. Only a secretless
// private_key_jwt account with a keyRef is eligible for helper reuse.
func (c CandidateApp) IsKeyless() bool {
return c.AppSecret.IsZero() && c.AuthMethod == AuthMethodPrivateKeyJWT && strings.TrimSpace(c.KeyRef) != ""
}
func bindableCredential(secret SecretInput, authMethod, keyRef string) bool {
return !secret.IsZero() || (authMethod == AuthMethodPrivateKeyJWT && strings.TrimSpace(keyRef) != "")
Label string
AppID string
AppSecret SecretInput
Brand string
}
// ListCandidateApps enumerates all bindable (enabled) apps from a FeishuChannel.
@@ -262,7 +243,7 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
if len(ch.Accounts) > 0 {
apps := make([]CandidateApp, 0, len(ch.Accounts)+1)
// When accounts exist AND top-level has its own bindable credential,
// When accounts exist AND top-level has its own appId+appSecret,
// include the top-level as a "default" candidate — aligned with
// openclaw-lark getLarkAccountIds() which adds DEFAULT_ACCOUNT_ID
// when top-level credentials are present and no explicit "default" exists.
@@ -273,15 +254,12 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
break
}
}
if !hasDefault && ch.AppID != "" && isEnabled(ch.Enabled) &&
bindableCredential(ch.AppSecret, ch.AuthMethod, ch.KeyRef) {
if !hasDefault && ch.AppID != "" && !ch.AppSecret.IsZero() && isEnabled(ch.Enabled) {
apps = append(apps, CandidateApp{
Label: "default",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
AuthMethod: ch.AuthMethod,
KeyRef: ch.KeyRef,
Label: "default",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
})
}
@@ -304,21 +282,11 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
if brand == "" {
brand = ch.Brand
}
authMethod := acct.AuthMethod
if authMethod == "" {
authMethod = ch.AuthMethod
}
keyRef := acct.KeyRef
if keyRef == "" {
keyRef = ch.KeyRef
}
apps = append(apps, CandidateApp{
Label: label,
AppID: appID,
AppSecret: appSecret,
Brand: brand,
AuthMethod: authMethod,
KeyRef: keyRef,
Label: label,
AppID: appID,
AppSecret: appSecret,
Brand: brand,
})
}
return apps
@@ -327,12 +295,10 @@ func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
// Single account at top level — check if channel itself is enabled
if ch.AppID != "" && isEnabled(ch.Enabled) {
return []CandidateApp{{
Label: "",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
AuthMethod: ch.AuthMethod,
KeyRef: ch.KeyRef,
Label: "",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
}}
}

View File

@@ -212,6 +212,9 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}

View File

@@ -518,6 +518,29 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
}
}
func TestDoStream_PreservesTypedTransportError(t *testing.T) {
policyErr := errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, "blocked redirect")
ac := &APIClient{
HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, policyErr
})},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/files/file_token/download",
}, core.AsBot)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("DoStream() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if !errors.Is(err, policyErr) {
t.Fatal("DoStream() did not preserve the typed transport error")
}
}
// failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{}

View File

@@ -16,10 +16,12 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/transport"
)
// Factory holds shared dependencies injected into every command.
@@ -31,7 +33,7 @@ type InvocationContext struct {
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
@@ -48,6 +50,18 @@ type Factory struct {
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ExternalHTTPClient returns a clone of the existing Factory client whose
// requests are explicitly classified as external. The underlying client,
// redirect policy, timeout, proxy configuration, and legacy transport provider
// behavior are preserved.
func (f *Factory) ExternalHTTPClient() (*http.Client, error) {
client, err := f.HttpClient()
if err != nil {
return nil, err
}
return transport.ClientForRequestClass(client, exttransport.RequestClassExternal), nil
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {

View File

@@ -5,16 +5,18 @@ package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
@@ -22,6 +24,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +36,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -47,6 +50,19 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
workspaceConfig := core.NewConfigSnapshot()
bootstrapHostSignalSource := sync.OnceValue(func() riskcontrol.Source {
return resolveSDKHostSignalSource(workspaceConfig)
})
// Install after workspace selection so the dependency bootstrap bridge uses
// the correct shared proxy configuration. NewDefault is also used by cmd.Build
// consumers, so this keeps their request routing identical to cmd.Execute.
transport.InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
return buildSDKPlatformTransportWithBase(
base,
bootstrapHostSignalSource(),
)
})
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
@@ -56,7 +72,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +83,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,21 +94,52 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
// safeRedirectPolicy prevents credential headers from being forwarded
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
// redirects; other headers like X-Cli-* pass through.
// safeRedirectPolicy permits cross-origin redirects only for bodyless GET and
// HEAD requests. This allows API download redirects while preventing OAuth or
// other credential-bearing request bodies from being replayed to another
// origin. HTTPS requests can never be downgraded to HTTP.
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
if len(via) == 0 {
return nil
}
original := via[0]
previous := via[len(via)-1]
if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"redirect from HTTPS to %s is not allowed",
req.URL.Scheme,
)
}
if !sameRedirectOrigin(previous.URL, req.URL) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"cross-origin redirect for HTTP method %s is not allowed",
req.Method,
)
}
if req.Body != nil || req.GetBody != nil {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"cross-origin redirect with a request body is not allowed",
)
}
}
// net/http copies initial headers onto every redirect request. Continue
// stripping credentials for every hop outside the initial origin, even when
// two consecutive redirect targets share an origin.
if !sameRedirectOrigin(original.URL, req.URL) {
req.Header.Del("Authorization")
req.Header.Del("X-Lark-MCP-UAT")
req.Header.Del("X-Lark-MCP-TAT")
@@ -100,6 +147,29 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
return nil
}
func sameRedirectOrigin(left, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectivePort(left) == effectivePort(right)
}
func effectivePort(candidate *url.URL) string {
if port := candidate.Port(); port != "" {
return port
}
switch strings.ToLower(candidate.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
// needed because the real function is guarded by an internal sync.Once, so
@@ -108,19 +178,19 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
shared := transport.Shared()
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
platform := buildDirectHTTPTransport(outbound, true)
external := buildDirectHTTPTransport(outbound, false)
client := &http.Client{
Transport: rt,
Transport: transport.NewHTTPPolicyRouter(platform, external),
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
@@ -128,7 +198,16 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func buildDirectHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
var builtIn http.RoundTripper = &RetryTransport{Base: base}
builtIn = &SecurityHeaderTransport{Base: builtIn}
if platform {
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
}
return builtIn
}
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +221,9 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: buildSDKTransport(hostSignalSource),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,13 +232,41 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
func buildSDKTransport(hostSignalSource riskcontrol.Source) http.RoundTripper {
return buildSDKTransportWithBase(transport.Shared(), hostSignalSource)
}
func buildSDKPlatformTransportWithBase(
base http.RoundTripper,
hostSignalSource riskcontrol.Source,
) http.RoundTripper {
outbound := riskcontrol.NewTransport(base, hostSignalSource)
return buildSDKHTTPTransport(outbound, true)
}
func buildSDKTransportWithBase(
base http.RoundTripper,
hostSignalSource riskcontrol.Source,
) http.RoundTripper {
// Risk control is the innermost trusted boundary for both request classes.
// It therefore observes the final URL and strips extension-supplied reserved
// headers immediately before the network transport.
outbound := riskcontrol.NewTransport(base, hostSignalSource)
return transport.NewHTTPPolicyRouter(
buildSDKHTTPTransport(outbound, true),
buildSDKHTTPTransport(outbound, false),
)
}
func buildSDKHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
var builtIn http.RoundTripper = &RetryTransport{Base: base}
builtIn = &UserAgentTransport{Base: builtIn}
builtIn = &BuildHeaderTransport{Base: builtIn}
builtIn = &SecurityHeaderTransport{Base: builtIn}
if platform {
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
}
return builtIn
}
type credentialDeps struct {

View File

@@ -4,12 +4,24 @@
package cmdutil
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -28,18 +40,304 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
}
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
}
}
func TestFactoryExternalHTTPClientClonesExistingClient(t *testing.T) {
base := &http.Client{Timeout: 17, CheckRedirect: safeRedirectPolicy}
factory := &Factory{HttpClient: func() (*http.Client, error) { return base, nil }}
external, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
if external == base {
t.Fatal("ExternalHTTPClient returned the cached client instead of a clone")
}
if external.Timeout != base.Timeout || external.CheckRedirect == nil {
t.Fatal("ExternalHTTPClient did not preserve client policy")
}
if base.Transport != nil {
t.Fatal("ExternalHTTPClient mutated the cached client's transport")
}
}
type platformOnlyStubProvider struct {
*stubTransportProvider
}
func (*platformOnlyStubProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == exttransport.RequestClassPlatform
}
func TestFactoryHTTPClientRoutesPoliciesByRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
interceptor := &headerCapturingInterceptor{}
exttransport.Register(&platformOnlyStubProvider{stubTransportProvider: &stubTransportProvider{interceptor: interceptor}})
t.Cleanup(func() { exttransport.Register(nil) })
received := make(chan http.Header, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Clone()
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
client, err := cachedHttpClientFunc(factory, nil)()
if err != nil {
t.Fatal(err)
}
factory.HttpClient = func() (*http.Client, error) { return client, nil }
platformClient := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
externalClient, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
for _, client := range []*http.Client{platformClient, externalClient} {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
platformHeaders := <-received
if got := platformHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("platform extension header = %q, want ext-trace-123", got)
}
if got := platformHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("platform security header = %q, want %q", got, SourceValue)
}
externalHeaders := <-received
if got := externalHeaders.Get("X-Custom-Trace"); got != "" {
t.Fatalf("external request leaked extension header %q", got)
}
for header, values := range BaseSecurityHeaders() {
if len(values) == 0 {
continue
}
want := values[len(values)-1]
if got := externalHeaders.Get(header); got != want {
t.Fatalf("external security header %s = %q, want preserved value %q", header, got, want)
}
}
}
func TestFactoryExternalHTTPClientDoesNotParsePlatformErrorProtocol(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
exttransport.Register(nil)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":21000,"msg":"application-defined external response","data":{"cli_hint":"external-defined"}}`)
}))
t.Cleanup(server.Close)
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
client, err := cachedHttpClientFunc(factory, nil)()
if err != nil {
t.Fatal(err)
}
factory.HttpClient = func() (*http.Client, error) { return client, nil }
platform := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
if _, err := platform.Get(server.URL); err == nil {
t.Fatal("platform request error = nil, want security policy classification")
} else {
var policyErr *errs.SecurityPolicyError
if !errors.As(err, &policyErr) {
t.Fatalf("platform request error type = %T, want *errs.SecurityPolicyError", err)
}
}
external, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
resp, err := external.Get(server.URL)
if err != nil {
t.Fatalf("external request parsed platform error protocol: %v", err)
}
resp.Body.Close()
}
func TestSafeRedirectPolicyAllowsBodylessCrossOriginGetAndStripsCredentials(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/file", nil)
if err != nil {
t.Fatal(err)
}
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
redirect.Header.Set(header, "secret")
}
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want allowed GET redirect", err)
}
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
if got := redirect.Header.Get(header); got != "" {
t.Fatalf("redirect retained %s=%q", header, got)
}
}
}
func TestSafeRedirectPolicyRejectsHTTPSDowngrade(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "http://open.feishu.cn/next", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("safeRedirectPolicy() error = %v, want HTTPS downgrade rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsCrossOriginMethod(t *testing.T) {
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodPost, "https://external.example/token", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "HTTP method POST") {
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin method rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsCrossOriginRequestBody(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://accounts.feishu.cn/token", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://external.example/token", strings.NewReader("client_secret=secret"))
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "request body") {
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin body rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsTooManyRedirects(t *testing.T) {
err := safeRedirectPolicy(&http.Request{}, make([]*http.Request, 10))
if err == nil || err.Error() != "too many redirects" {
t.Fatalf("safeRedirectPolicy() error = %v, want redirect limit rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
func TestSafeRedirectPolicyTreatsDefaultHTTPSPortAsSameOrigin(t *testing.T) {
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", strings.NewReader("secret"))
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn:443/token-next", strings.NewReader("secret"))
if err != nil {
t.Fatal(err)
}
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want same-origin redirect", err)
}
}
func TestSafeRedirectPolicyKeepsCredentialsStrippedAcrossExternalHops(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/first", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/second", nil)
if err != nil {
t.Fatal(err)
}
redirect.Header.Set("Authorization", "Bearer copied-from-initial-request")
if err := safeRedirectPolicy(redirect, []*http.Request{original, previous}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want same-CDN redirect", err)
}
if got := redirect.Header.Get("Authorization"); got != "" {
t.Fatalf("redirect retained Authorization=%q outside the initial origin", got)
}
}
func TestSafeRedirectPolicyRejectsDowngradeOnLaterHop(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "http://source.example/start", nil)
if err != nil {
t.Fatal(err)
}
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/secure", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "http://cdn.example.com/plain", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original, previous})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("safeRedirectPolicy() error = %v, want later-hop HTTPS downgrade rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func requireRedirectProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed error", err)
}
if problem.Category != category || problem.Subtype != subtype {
t.Fatalf(
"error category/subtype = %s/%s, want %s/%s",
problem.Category,
problem.Subtype,
category,
subtype,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -46,7 +46,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) {
URL: "/test",
Body: "ok",
})
// Use the stub via Factory HttpClient
// Use the stub via Factory HttpClient.
httpClient, err := f.HttpClient()
if err != nil {
t.Fatalf("HttpClient() error: %v", err)

View File

@@ -4,14 +4,19 @@
package cmdutil
import (
"context"
"net/http"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/transport"
)
var (
_ transport.RoundTripperDecorator = (*RetryTransport)(nil)
_ transport.RoundTripperDecorator = (*UserAgentTransport)(nil)
_ transport.RoundTripperDecorator = (*BuildHeaderTransport)(nil)
_ transport.RoundTripperDecorator = (*SecurityHeaderTransport)(nil)
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
// and network errors. MaxRetries defaults to 0 (no retries).
type RetryTransport struct {
@@ -27,6 +32,16 @@ func (t *RetryTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *RetryTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *RetryTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *RetryTransport) delay() time.Duration {
if t.Delay > 0 {
return t.Delay
@@ -63,6 +78,19 @@ type UserAgentTransport struct {
Base http.RoundTripper
}
func (t *UserAgentTransport) BaseRoundTripper() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
func (t *UserAgentTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderUserAgent, UserAgentValue())
@@ -73,14 +101,25 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
// X-Cli-Build header before every request. Used in the SDK transport chain,
// where SecurityHeaderTransport is not installed, to prevent extensions from
// tampering with the build classification. The direct HTTP chain is already
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
// X-Cli-Build header before every request. It remains in the SDK transport
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
type BuildHeaderTransport struct {
Base http.RoundTripper
}
func (t *BuildHeaderTransport) BaseRoundTripper() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
func (t *BuildHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderBuild, DetectBuildKind())
@@ -103,6 +142,16 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *SecurityHeaderTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *SecurityHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
@@ -120,67 +169,3 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
}
return t.base().RoundTrip(req)
}
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
// The built-in chain always executes unless the extension is an
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
// error; it cannot otherwise be skipped or overridden.
//
// The original request context is restored after the pre hook to prevent
// extensions from tampering with cancellation, deadlines, or built-in values.
// Cloning the request isolates header/URL/etc. mutations from the caller's
// request object; req.Body is intentionally shared — extensions that consume
// it are responsible for rewinding (see Interceptor doc).
type extensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
}
// RoundTrip invokes the interceptor pre hook, restores the original context,
// executes the built-in chain (unless aborted), then calls the post hook if
// non-nil. When the extension implements AbortableInterceptor and returns a
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
// *exttransport.AbortError is returned; the post hook is still invoked with
// (nil, reason) so extensions can unwind resources.
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortEr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortEr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortEr != nil {
if post != nil {
post(nil, abortEr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
}
req = req.WithContext(origCtx) // restore original context
resp, err := m.Base.RoundTrip(req)
if post != nil {
post(resp, err)
}
return resp, err
}
// wrapWithExtension wraps transport with the registered extension middleware.
// If no extension is registered, returns transport unchanged.
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
p := exttransport.GetProvider()
if p == nil {
return transport
}
tr := p.ResolveInterceptor(context.Background())
if tr == nil {
return transport
}
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
}

View File

@@ -14,7 +14,8 @@ import (
"time"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
internaltransport "github.com/larksuite/cli/internal/transport"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -90,79 +91,107 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// buildSDKTransport policy behavior
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestBuildSDKTransportAppliesSecurityHeadersToEveryRequestClass(t *testing.T) {
exttransport.Register(nil)
received := make(chan http.Header, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Clone()
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
for _, class := range []exttransport.RequestClass{
exttransport.RequestClassPlatform,
exttransport.RequestClassExternal,
} {
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransport(nil)},
class,
)
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
headers := <-received
for header, values := range BaseSecurityHeaders() {
if len(values) == 0 {
continue
}
want := values[len(values)-1]
if got := headers.Get(header); got != want {
t.Fatalf("SDK %s header %s = %q, want %q", class, header, got, want)
}
}
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
previous := exttransport.GetProvider()
interceptor := &headerCapturingInterceptor{}
exttransport.Register(&platformOnlyStubProvider{
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
})
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransport(nil)},
exttransport.RequestClassPlatform,
)
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
resp.Body.Close()
if !interceptor.preCalled || !interceptor.postCalled {
t.Fatal("SDK platform request did not execute extension pre/post hooks")
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
t.Fatalf(
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
buildSDKTransport(nil),
)
}
}
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
func TestBuildSDKTransportSupportsPolicyLeafCloning(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
base := &http.Transport{}
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransportWithBase(base, nil)},
exttransport.RequestClassExternal,
)
source, ok := client.Transport.(interface {
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
})
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
rebuilt, concrete, ok := source.CloneHTTPTransport()
if !ok || rebuilt == nil || concrete == nil {
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
if concrete == base {
t.Fatal("SDK policy graph reused the original HTTP transport")
}
}
@@ -222,7 +251,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
transport := internaltransport.WrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
@@ -250,26 +279,132 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
}
}
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
// value before the request leaves the process.
// buildTamperingInterceptor tries to delete and spoof security headers via
// PreRoundTrip. The SDK built-in chain must restore the real values before the
// request leaves the process.
type buildTamperingInterceptor struct{}
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Del(HeaderBuild)
req.Header.Set(HeaderBuild, "ext-tampered-build")
req.Header.Del(HeaderSource)
req.Header.Set(HeaderSource, "ext-tampered-source")
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
type bootstrapPolicyTamperingInterceptor struct{}
func (bootstrapPolicyTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(HeaderSource, "extension-value")
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
return nil
}
func TestNewDefaultInstallsSDKBootstrapSecurityPolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldTransport := http.DefaultClient.Transport
oldCheckRedirect := http.DefaultClient.CheckRedirect
t.Cleanup(func() {
http.DefaultClient.Transport = oldTransport
http.DefaultClient.CheckRedirect = oldCheckRedirect
})
previous := exttransport.GetProvider()
exttransport.Register(&platformOnlyStubProvider{
stubTransportProvider: &stubTransportProvider{
interceptor: bootstrapPolicyTamperingInterceptor{},
},
})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})
http.DefaultClient.Transport = network
http.DefaultClient.CheckRedirect = nil
_ = NewDefault(nil, InvocationContext{})
req, err := http.NewRequest(
http.MethodPost,
"https://open.feishu.cn/callback/ws/endpoint",
strings.NewReader(`{"app_secret":"secret"}`),
)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := received.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want trusted value %q", HeaderSource, got, SourceValue)
}
if got := received.Get(riskcontrol.HeaderOSType); got != "" {
t.Fatalf("%s = %q, want extension value stripped", riskcontrol.HeaderOSType, got)
}
if got := received.Get(HeaderBuild); got != DetectBuildKind() {
t.Fatalf("%s = %q, want %q", HeaderBuild, got, DetectBuildKind())
}
if got := received.Get(HeaderUserAgent); got != UserAgentValue() {
t.Fatalf("%s = %q, want %q", HeaderUserAgent, got, UserAgentValue())
}
}
func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
exttransport.RequestClassPlatform,
)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
// closes the gap where the SDK chain had no equivalent of
// SecurityHeaderTransport (see design doc §3.3.3).
// SDK chain restores both the build classification and the full security
// header set after an extension runs.
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
var receivedBuild string
var receivedBuild, receivedSource string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
receivedSource = r.Header.Get(HeaderSource)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
@@ -277,12 +412,13 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK built-in chain inside buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}
base = &BuildHeaderTransport{Base: base}
transport := wrapWithExtension(base)
base = &SecurityHeaderTransport{Base: base}
transport := internaltransport.WrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
@@ -299,6 +435,9 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
if receivedSource != SourceValue {
t.Fatalf("%s = %q, want %q", HeaderSource, receivedSource, SourceValue)
}
}
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
@@ -388,7 +527,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
@@ -450,7 +589,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
@@ -491,7 +630,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
return nil, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
_, err := mid.RoundTrip(req)
@@ -510,7 +649,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if err != nil {

View File

@@ -36,14 +36,6 @@ type AppUser struct {
UserName string `json:"userName"`
}
// Auth methods for app credentials. An empty AppConfig.AuthMethod means the
// default, client_secret.
const (
AuthMethodClientSecret = "client_secret" // app_id + app_secret
authMethodPKJWTValue = "private_key_jwt" // TEE-signed client_assertion; no app secret
AuthMethodPrivateKeyJWT = authMethodPKJWTValue
)
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
type AppConfig struct {
Name string `json:"name,omitempty"`
@@ -54,15 +46,6 @@ type AppConfig struct {
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
// AuthMethod selects how tokens are minted. Empty == AuthMethodClientSecret
// (back-compat). AuthMethodPrivateKeyJWT uses a TEE-held key (see KeyRef) to
// sign client_assertion JWTs instead of sending an app secret.
AuthMethod string `json:"authMethod,omitempty"`
// KeyRef references the non-exportable signing key for private_key_jwt.
// Source is "tee" and ID is the backend key label; the actual key never
// leaves the secure backend, so this is a handle, not secret material.
KeyRef *SecretRef `json:"keyRef,omitempty"`
}
// ProfileName returns the display name for this app config.
@@ -77,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
@@ -178,10 +168,7 @@ type CliConfig struct {
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
AuthMethod string // "" == client_secret; AuthMethodPrivateKeyJWT
KeyLabel string // resolved TEE key handle for private_key_jwt
KeyProvider string // empty == built-in signer; otherwise an explicit external signer route
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
@@ -267,67 +254,31 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
}
// Validate the auth method first so a malformed profile fails here rather
// than silently degrading to client_secret (unknown method) or failing later
// at token-signing. Empty stays empty — downstream treats it as client_secret
// (back-compat).
switch app.AuthMethod {
case "", AuthMethodClientSecret, AuthMethodPrivateKeyJWT:
default:
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q", app.AuthMethod).
WithHint("supported: %s, %s (empty defaults to %s)", AuthMethodClientSecret, AuthMethodPrivateKeyJWT, AuthMethodClientSecret)
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}
// private_key_jwt carries no secret: validate the key handle and skip secret
// resolution entirely, so a stale/broken AppSecret ref never produces a
// confusing secret-resolution error for an otherwise-valid pkjwt profile.
var secret string
if app.AuthMethod == AuthMethodPrivateKeyJWT {
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "private_key_jwt requires a key handle (keyRef) but none is configured").
WithHint("re-run: lark-cli config init --new --private-key-jwt")
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
provider := strings.TrimSpace(app.KeyRef.Provider)
switch provider {
case "", KeylessProviderLarkSuite:
default:
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig,
"unknown keyless signer provider %q", app.KeyRef.Provider).
WithHint("supported external provider: %s; omit provider to use the built-in signer", KeylessProviderLarkSuite)
}
} else {
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}
var resolveErr error
secret, resolveErr = ResolveSecretInput(app.AppSecret, kc)
if resolveErr != nil {
if errs.IsTyped(resolveErr) {
return nil, resolveErr
}
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(resolveErr) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "%s", resolveErr.Error()).WithCause(resolveErr)
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
}
cfg := &CliConfig{
ProfileName: app.ProfileName(),
AppID: app.AppId,
AppSecret: secret,
Brand: ParseBrand(string(app.Brand)),
Lang: app.Lang,
AuthMethod: app.AuthMethod,
DefaultAs: app.DefaultAs,
}
if app.KeyRef != nil {
cfg.KeyLabel = app.KeyRef.ID
cfg.KeyProvider = strings.TrimSpace(app.KeyRef.Provider)
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId
cfg.UserName = app.Users[0].UserName

View File

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

View File

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

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,34 +86,8 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
}
func TestResolveConfigFromMulti_KeyProviderRouting(t *testing.T) {
base := AppConfig{
AppId: "cli_pk", Brand: BrandFeishu, AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: &SecretRef{Source: SecretSourceTEE, ID: "key-1"}, Users: []AppUser{},
}
for _, provider := range []string{"", KeylessProviderLarkSuite} {
app := base
ref := *base.KeyRef
ref.Provider = provider
app.KeyRef = &ref
cfg, err := ResolveConfigFromMulti(&MultiAppConfig{Apps: []AppConfig{app}}, stubKeychain{}, "")
if err != nil {
t.Fatalf("provider %q: %v", provider, err)
}
if cfg.KeyProvider != provider {
t.Fatalf("KeyProvider = %q, want %q", cfg.KeyProvider, provider)
}
}
app := base
ref := *base.KeyRef
ref.Provider = "unknown.provider"
app.KeyRef = &ref
if _, err := ResolveConfigFromMulti(&MultiAppConfig{Apps: []AppConfig{app}}, stubKeychain{}, ""); err == nil {
t.Fatal("unknown provider must fail closed")
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
@@ -162,108 +138,6 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
}
}
// TestResolveConfigFromMulti_RejectsUnknownAuthMethod ensures an unsupported
// authMethod fails at resolution rather than silently degrading to client_secret.
func TestResolveConfigFromMulti_RejectsUnknownAuthMethod(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
AuthMethod: "bogus_method",
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for unknown authMethod")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
}
// TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef ensures private_key_jwt
// without a key handle fails at resolution rather than later at token-signing.
func TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{}, // private_key_jwt carries no app secret
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
// KeyRef intentionally nil
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for private_key_jwt without keyRef")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
// Control: same config WITH a keyRef resolves cleanly and sets KeyLabel.
raw.Apps[0].KeyRef = &SecretRef{Source: "tee", ID: "larksuite-cli-agent"}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error with keyRef present: %v", err)
}
if cfg.KeyLabel != "larksuite-cli-agent" {
t.Errorf("KeyLabel = %q, want larksuite-cli-agent", cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTSkipsSecretResolution ensures a private_key_jwt
// profile that carries a stale/broken AppSecret ref still resolves cleanly: the
// auth method is judged before any secret handling, so the stale ref is ignored
// instead of producing a confusing secret-resolution failure.
func TestResolveConfigFromMulti_PKJWTSkipsSecretResolution(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{{
AppId: "cli_pk",
// Stale keychain ref whose ID does not match appId — would trip
// ValidateSecretKeyMatch / ResolveSecretInput if it were reached.
AppSecret: SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_OTHER"}},
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: &SecretRef{Source: "tee", ID: "agent-key"},
Users: []AppUser{},
}},
}
cfg, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err != nil {
t.Fatalf("pkjwt with stale secret ref must skip secret resolution, got %v", err)
}
if cfg.AuthMethod != AuthMethodPrivateKeyJWT || cfg.KeyLabel != "agent-key" {
t.Errorf("got authMethod=%q keyLabel=%q", cfg.AuthMethod, cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef ensures the stricter keyRef
// check (Source=="tee" && ID!="") rejects malformed handles.
func TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef(t *testing.T) {
for i, ref := range []*SecretRef{
{Source: "keychain", ID: "x"}, // wrong source
{Source: "tee", ID: ""}, // empty id
} {
raw := &MultiAppConfig{Apps: []AppConfig{{
AppId: "cli_pk", Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: ref, Users: []AppUser{},
}}}
if _, err := ResolveConfigFromMulti(raw, stubKeychain{}, ""); err == nil {
t.Errorf("case %d: expected ConfigError for bad keyRef", i)
}
}
}
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{

View File

@@ -19,12 +19,6 @@ type SecretRef struct {
ID string `json:"id"` // env var name / file path / command / keychain key
}
// KeylessProviderLarkSuite is the only external private_key_jwt signer route.
// An absent or empty provider always means the CLI's built-in signer.
const KeylessProviderLarkSuite = "larksuite.keyless"
const SecretSourceTEE = "tee"
// ---------------------------------------------------------------------------
// SecretInput — union type: plain string or SecretRef
// ---------------------------------------------------------------------------

View File

@@ -3,7 +3,10 @@
package core
import "strings"
import (
"net/url"
"strings"
)
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
@@ -64,9 +67,38 @@ func ResolveOpenBaseURL(brand LarkBrand) string {
return ResolveEndpoints(brand).Open
}
// OpenAPIAudience returns the client_assertion `aud` value for the brand: the
// bare Open API host per the App Authentication JWT spec — "open.feishu.cn" or
// "open.larksuite.com" — not the full token endpoint URL.
func OpenAPIAudience(brand LarkBrand) string {
return strings.TrimPrefix(ResolveOpenBaseURL(brand), "https://")
var platformEndpointHosts = func() map[string]struct{} {
hosts := make(map[string]struct{})
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
endpoints := ResolveEndpoints(brand)
for _, rawURL := range []string{endpoints.Open, endpoints.Accounts, endpoints.MCP, endpoints.AppLink} {
parsed, err := url.Parse(rawURL)
if err == nil && parsed.Hostname() != "" {
hosts[strings.ToLower(parsed.Hostname())] = struct{}{}
}
}
}
return hosts
}()
// IsPlatformEndpointHost reports whether hostname exactly matches one of the
// endpoint hosts produced by ResolveEndpoints. It intentionally does not use a
// suffix match: lookalike external domains must never enter the platform
// transport extension.
func IsPlatformEndpointHost(hostname string) bool {
_, ok := platformEndpointHosts[strings.ToLower(hostname)]
return ok
}
// IsPlatformEndpointURL reports whether candidate uses a secure origin for a
// configured platform endpoint. Non-TLS and non-standard-port lookalikes are
// excluded even when their hostname matches.
func IsPlatformEndpointURL(candidate *url.URL) bool {
if candidate == nil || !strings.EqualFold(candidate.Scheme, "https") {
return false
}
if port := candidate.Port(); port != "" && port != "443" {
return false
}
return IsPlatformEndpointHost(candidate.Hostname())
}

View File

@@ -3,7 +3,11 @@
package core
import "testing"
import (
"net/url"
"reflect"
"testing"
)
func TestResolveEndpoints_Feishu(t *testing.T) {
ep := ResolveEndpoints(BrandFeishu)
@@ -92,11 +96,84 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
}
}
func TestOpenAPIAudience(t *testing.T) {
if got := OpenAPIAudience(BrandFeishu); got != "open.feishu.cn" {
t.Errorf("OpenAPIAudience(feishu) = %q, want open.feishu.cn", got)
func TestIsPlatformEndpointHost_ExactMatchOnly(t *testing.T) {
for _, host := range []string{
"open.feishu.cn",
"accounts.feishu.cn",
"mcp.feishu.cn",
"applink.feishu.cn",
"open.larksuite.com",
"accounts.larksuite.com",
"mcp.larksuite.com",
"applink.larksuite.com",
} {
if !IsPlatformEndpointHost(host) {
t.Errorf("IsPlatformEndpointHost(%q) = false, want true", host)
}
}
if got := OpenAPIAudience(BrandLark); got != "open.larksuite.com" {
t.Errorf("OpenAPIAudience(lark) = %q, want open.larksuite.com", got)
for _, host := range []string{
"example.com",
"open.feishu.cn.example.com",
"notopen.feishu.cn",
"",
} {
if IsPlatformEndpointHost(host) {
t.Errorf("IsPlatformEndpointHost(%q) = true, want false", host)
}
}
}
func TestIsPlatformEndpointHost_CoversEveryResolvedEndpoint(t *testing.T) {
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
endpoints := reflect.ValueOf(ResolveEndpoints(brand))
for i := 0; i < endpoints.NumField(); i++ {
rawURL := endpoints.Field(i).String()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("ResolveEndpoints(%q) field %d URL %q: %v", brand, i, rawURL, err)
}
if !IsPlatformEndpointHost(parsed.Hostname()) {
t.Errorf("ResolveEndpoints(%q) field %d host %q is missing from the platform transport boundary", brand, i, parsed.Hostname())
}
}
}
}
func TestIsPlatformEndpointURL_RequiresSecureStandardOrigin(t *testing.T) {
if IsPlatformEndpointURL(nil) {
t.Error("IsPlatformEndpointURL(nil) = true, want false")
}
uppercaseScheme := &url.URL{Scheme: "HTTPS", Host: "open.feishu.cn", Path: "/path"}
if !IsPlatformEndpointURL(uppercaseScheme) {
t.Error("IsPlatformEndpointURL() rejected uppercase HTTPS scheme")
}
for _, rawURL := range []string{
"http://open.feishu.cn/path",
"https://open.feishu.cn:8443/path",
"https://open.feishu.cn.example.com/path",
} {
candidate, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
if IsPlatformEndpointURL(candidate) {
t.Errorf("IsPlatformEndpointURL(%q) = true, want false", rawURL)
}
}
for _, rawURL := range []string{
"https://open.feishu.cn/path",
"https://open.feishu.cn:443/path",
"https://OPEN.FEISHU.CN/path",
} {
candidate, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
if !IsPlatformEndpointURL(candidate) {
t.Errorf("IsPlatformEndpointURL(%q) = false, want true", rawURL)
}
}
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/keychain"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/keysigner"
)
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
@@ -176,18 +175,6 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil {
return nil, err
}
// private_key_jwt apps have no app secret: mint via the jwt-bearer grant
// using a TEE-signed client_assertion instead.
if acct.AuthMethod == core.AuthMethodPrivateKeyJWT {
signer := keysigner.Active()
token, err := FetchTATWithAssertionForProvider(ctx, httpClient, acct.Brand, acct.AppID, signer, acct.KeyProvider, acct.KeyLabel)
if err != nil {
return nil, err
}
return &TokenResult{Token: token}, nil
}
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
if err != nil {
return nil, err

View File

@@ -12,12 +12,7 @@ import (
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keylesshelper"
"github.com/larksuite/cli/internal/keylessprovider"
"github.com/larksuite/cli/internal/keysigner"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
@@ -105,120 +100,3 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
}
// FetchTATWithAssertion mints a tenant access token for a private_key_jwt app via
// the RFC 7523 jwt-bearer grant: it signs a short-lived client_assertion with the
// TEE-held key and posts it to the unified OAuth token endpoint, replacing the
// app_secret entirely.
//
// The unified v2 token endpoint returns the minted token as access_token
// (tenant_access_token is accepted as a fallback).
func FetchTATWithAssertion(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) (string, error) {
return FetchTATWithAssertionForProvider(ctx, httpClient, brand, clientID, signer, "", keyLabel)
}
// FetchTATWithAssertionForProvider routes one app authentication by its
// persisted keyRef.provider. Empty is the stable built-in signer route;
// larksuite.keyless is resolved afresh for this operation; all other values
// fail closed in keylessprovider.Resolve.
func FetchTATWithAssertionForProvider(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, provider, keyLabel string) (string, error) {
var helper *keylesshelper.Command
var err error
if strings.TrimSpace(provider) != "" {
helper, err = keylessprovider.Resolve(ctx, provider)
if err != nil {
return "", err
}
}
return FetchTATWithAssertionWithHelper(ctx, httpClient, brand, clientID, signer, helper, keyLabel)
}
// FetchTATWithAssertionWithHelper is the single-resolution variant used when
// the caller must make a preflight decision from the same helper snapshot.
func FetchTATWithAssertionWithHelper(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, helper *keylesshelper.Command, keyLabel string) (string, error) {
if signer == nil && helper == nil {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
"profile uses private_key_jwt but no TEE key signer is available on this build").
WithHint("install a build with the platform key-signer extension, configure an external keyless signer, or reconfigure the app to use an app secret")
}
ep := core.ResolveEndpoints(brand)
endpoint := ep.Open + auth.PathOAuthTokenV2
assertionType, assertion, err := auth.SignClientAssertion(ctx, signer, helper, keyLabel, clientID, core.OpenAPIAudience(brand))
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("client_id", clientID)
form.Set("client_assertion_type", assertionType)
form.Set("client_assertion", assertion)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("read token response: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
AccessToken string `json:"access_token"`
TenantAccessToken string `json:"tenant_access_token"`
}
_ = json.Unmarshal(body, &result) // best-effort; error body may not be JSON
token := result.AccessToken
if token == "" {
token = result.TenantAccessToken
}
if resp.StatusCode == http.StatusOK && token != "" && result.Error == "" && result.Code == 0 {
return token, nil
}
// Surface the server's reason, preferring the OAuth `error` code (e.g.
// unauthorized_client) which is more diagnostic than the description alone.
detail := result.ErrorDescription
if detail == "" {
detail = result.Msg
}
if detail == "" {
detail = strings.TrimSpace(string(body))
}
if result.Error != "" {
return "", classifyAssertionError(result.Error, resp.StatusCode, detail)
}
return "", fmt.Errorf("token endpoint HTTP %d (code=%d): %s", resp.StatusCode, result.Code, detail)
}
// classifyAssertionError maps the OAuth token endpoint's `error` field to a
// typed or untyped error. Only deterministic client-credential rejections get a
// typed errs.ConfigError (so runProbePKJWT can tell "this key is not bound to
// this app" apart from upstream noise); every other error (e.g.
// temporarily_unavailable) stays untyped and is swallowed by the probe. detail
// carries only the server's error_description / msg / body text — it never
// echoes the client_assertion or private key (the assertion lives only in the
// request form).
func classifyAssertionError(oauthError string, httpStatus int, detail string) error {
switch oauthError {
case "invalid_client", "unauthorized_client", "invalid_grant":
return errs.NewConfigError(errs.SubtypeInvalidClient,
"token endpoint rejected the key (%s): %s", oauthError, detail)
default:
return fmt.Errorf("token endpoint HTTP %d (%s): %s", httpStatus, oauthError, detail)
}
}

View File

@@ -5,22 +5,15 @@ package credential
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// stubRoundTripper lets us assert request shape and return canned responses.
@@ -314,141 +307,3 @@ func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}
// fakeTATSigner is a real in-memory ECDSA P-256 signer for assertion tests.
type fakeTATSigner struct{ key *ecdsa.PrivateKey }
func newFakeTATSigner(t *testing.T) *fakeTATSigner {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeTATSigner{key: k}
}
func (f *fakeTATSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestFetchTATWithAssertion_Success(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"access_token":"test-token","token_type":"Bearer","expires_in":7200}`}
hc := &http.Client{Transport: rt}
token, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "agent-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "test-token" {
t.Errorf("token = %q, want test-token", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
form, err := url.ParseQuery(rt.gotBody)
if err != nil {
t.Fatal(err)
}
if form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
t.Errorf("grant_type = %q", form.Get("grant_type"))
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be sent for private_key_jwt")
}
if form.Get("client_id") != "cli_app" {
t.Errorf("client_id = %q", form.Get("client_id"))
}
}
func TestFetchTATWithAssertion_NilSigner(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hc := &http.Client{Transport: &stubRoundTripper{respCode: 200, respBody: `{}`}}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", nil, "k"); err == nil {
t.Fatal("expected error when signer is nil")
}
}
func TestFetchTATWithAssertion_ServerError(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"error":"invalid_client","error_description":"unknown key"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k"); err == nil {
t.Fatal("expected error for invalid_client response")
}
}
func TestFetchTATWithAssertion_LimitsErrorBody(t *testing.T) {
rt := &stubRoundTripper{respCode: 502, respBody: strings.Repeat("x", 2<<20)}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatal("expected error")
}
if len(err.Error()) > (1<<20)+512 {
t.Fatalf("error length = %d, want bounded", len(err.Error()))
}
}
// Deterministic OAuth client rejections must be typed (ConfigError /
// SubtypeInvalidClient) so runProbePKJWT can tell "the key is not bound to this
// app" apart from transport noise.
func TestFetchTATWithAssertion_DeterministicReject_Typed(t *testing.T) {
for _, oauthErr := range []string{"invalid_client", "unauthorized_client", "invalid_grant"} {
rt := &stubRoundTripper{respCode: 401, respBody: `{"error":"` + oauthErr + `","error_description":"bad key"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("%s: expected error", oauthErr)
}
if !errs.IsTyped(err) {
t.Errorf("%s: must be typed, got %T", oauthErr, err)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("%s: want ConfigError/InvalidClient, got %T %v", oauthErr, err, err)
}
}
}
// Unrecognized OAuth errors and non-payload noise stay UNTYPED so the probe
// treats them as upstream noise and stays silent.
func TestFetchTATWithAssertion_AmbiguousError_Untyped(t *testing.T) {
cases := []string{
`{"error":"temporarily_unavailable","error_description":"retry"}`,
`{"code":99999,"msg":"weird"}`,
`not json`,
}
for _, body := range cases {
rt := &stubRoundTripper{respCode: 503, respBody: body}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("body %q: expected error", body)
}
if errs.IsTyped(err) {
t.Errorf("body %q: must be UNTYPED, got typed %T", body, err)
}
}
}

View File

@@ -26,9 +26,6 @@ type Account struct {
UserName string
Lang i18n.Lang
SupportedIdentities uint8
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // resolved TEE key handle for private_key_jwt
KeyProvider string // empty == built-in signer; explicit external provider otherwise
}
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
@@ -72,9 +69,6 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
UserName: cfg.UserName,
Lang: cfg.Lang,
SupportedIdentities: cfg.SupportedIdentities,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
KeyProvider: cfg.KeyProvider,
}
}
@@ -94,9 +88,6 @@ func (a *Account) ToCliConfig() *core.CliConfig {
UserName: a.UserName,
Lang: a.Lang,
SupportedIdentities: a.SupportedIdentities,
AuthMethod: a.AuthMethod,
KeyLabel: a.KeyLabel,
KeyProvider: a.KeyProvider,
}
}

View File

@@ -56,16 +56,13 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
UserName: "alice",
Lang: i18n.LangJaJP,
SupportedIdentities: 3,
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "openclaw-lark",
KeyProvider: core.KeylessProviderLarkSuite,
}
acct := AccountFromCliConfig(cfg)
if acct == nil {
t.Fatal("AccountFromCliConfig() = nil")
}
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName || acct.KeyProvider != cfg.KeyProvider {
if acct.AppID != cfg.AppID || acct.ProfileName != cfg.ProfileName || acct.UserName != cfg.UserName {
t.Fatalf("AccountFromCliConfig() = %#v, want copied fields from %#v", acct, cfg)
}
if acct.Lang != cfg.Lang {
@@ -76,7 +73,7 @@ func TestAccountFromCliConfigAndBack_ReturnCopies(t *testing.T) {
if roundtrip == nil {
t.Fatal("ToCliConfig() = nil")
}
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName || roundtrip.KeyProvider != cfg.KeyProvider {
if roundtrip.AppID != cfg.AppID || roundtrip.ProfileName != cfg.ProfileName || roundtrip.UserName != cfg.UserName {
t.Fatalf("ToCliConfig() = %#v, want copied fields from %#v", roundtrip, cfg)
}
if roundtrip.Lang != cfg.Lang {

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

@@ -202,9 +202,7 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
Hint: "check strict mode or the active credential provider",
}
}
// private_key_jwt apps have no app secret — the bot/tenant token is minted via
// a TEE-signed client_assertion — so absence of a secret is NOT "unconfigured".
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) && cfg.AuthMethod != core.AuthMethodPrivateKeyJWT {
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (missing app secret or bot token)",

View File

@@ -1,287 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keylesshelper invokes a signer generation that has already been
// resolved and verified by internal/keylessprovider.
package keylesshelper
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/vfs"
)
const (
helperOutputLimit = 1 << 20
helperStderrLimit = 64 << 10
helperExecutionTimeout = 10 * time.Second
)
type request struct {
Op string `json:"op"`
KeyRef string `json:"keyRef,omitempty"`
Nonce string `json:"nonce,omitempty"`
ClientID string `json:"clientId,omitempty"`
Audience string `json:"aud,omitempty"`
}
type response struct {
OK bool `json:"ok"`
Error *protocolError `json:"error,omitempty"`
Attestation string `json:"attestation,omitempty"`
ClientAssertionType string `json:"client_assertion_type,omitempty"`
ClientAssertion string `json:"client_assertion,omitempty"`
}
type protocolError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// Command is one verified provider executable. It is intentionally impossible
// to construct from an app-config path, argv, or environment variable.
type Command struct {
argv []string
providerCWD string
providerHome string
providerSHA string
}
// NewProviderCommand builds the fixed empty-argv command used by a verified
// provider executable. Provider execution never accepts argv from app config or
// the environment.
func NewProviderCommand(binaryPath, providerRoot, signerHome, expectedSHA256 string) (*Command, error) {
if strings.TrimSpace(binaryPath) == "" || strings.TrimSpace(providerRoot) == "" {
return nil, fmt.Errorf("provider binary path and root must be non-empty")
}
if len(expectedSHA256) != 64 {
return nil, fmt.Errorf("provider binary digest must be a SHA-256 hex string")
}
return &Command{argv: []string{binaryPath}, providerCWD: providerRoot, providerHome: signerHome, providerSHA: expectedSHA256}, nil
}
// Probe asks this resolved helper for its public key.
func (c *Command) Probe(ctx context.Context, keyRef string) error {
resp, err := c.execute(ctx, request{
Op: "pubkey",
KeyRef: defaultKeyRef(keyRef),
})
if err != nil {
return err
}
return validateResponse(resp)
}
// SignAttestation asks this resolved helper to mint a registration attestation JWT.
func (c *Command) SignAttestation(ctx context.Context, keyRef, nonce string) (string, error) {
resp, err := c.execute(ctx, request{
Op: "sign-attestation",
KeyRef: defaultKeyRef(keyRef),
Nonce: nonce,
})
if err != nil {
return "", err
}
if err := validateResponse(resp); err != nil {
return "", err
}
if resp.Attestation == "" {
return "", fmt.Errorf("keyless helper returned empty attestation")
}
return resp.Attestation, nil
}
// SignClientAssertion asks this resolved helper to mint a token-endpoint client_assertion.
func (c *Command) SignClientAssertion(ctx context.Context, keyRef, clientID, audience string) (string, string, error) {
resp, err := c.execute(ctx, request{
Op: "sign-assertion",
KeyRef: defaultKeyRef(keyRef),
ClientID: clientID,
Audience: audience,
})
if err != nil {
return "", "", err
}
if err := validateResponse(resp); err != nil {
return "", "", err
}
if resp.ClientAssertionType == "" {
return "", "", fmt.Errorf("keyless helper returned empty client_assertion_type")
}
if resp.ClientAssertion == "" {
return "", "", fmt.Errorf("keyless helper returned empty client_assertion")
}
return resp.ClientAssertionType, resp.ClientAssertion, nil
}
func (c *Command) execute(ctx context.Context, req request) (response, error) {
if err := verifyProviderBinary(c.argv[0], c.providerSHA); err != nil {
return response{}, err
}
return runCommandConfigured(ctx, c.argv, req, c.providerCWD, providerEnvironment(c.providerHome))
}
func verifyProviderBinary(path, expectedSHA string) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("recheck provider signer: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > 512<<20 {
return fmt.Errorf("provider signer changed before execution")
}
f, err := vfs.Open(path)
if err != nil {
return fmt.Errorf("reopen provider signer: %w", err)
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, io.LimitReader(f, 512<<20+1))
if err != nil || n != info.Size() {
return fmt.Errorf("rehash provider signer: file changed while reading")
}
if hex.EncodeToString(h.Sum(nil)) != expectedSHA {
return fmt.Errorf("provider signer digest changed before execution")
}
return nil
}
func validateResponse(resp response) error {
if resp.Error != nil {
return fmt.Errorf("keyless helper %s: %s", resp.Error.Type, resp.Error.Message)
}
if !resp.OK {
return fmt.Errorf("keyless helper returned ok=false")
}
return nil
}
func defaultKeyRef(keyRef string) string {
if keyRef != "" {
return keyRef
}
return keysigner.DefaultKeyLabel
}
func runCommand(ctx context.Context, argv []string, req request) (response, error) {
return runCommandConfigured(ctx, argv, req, "", nil)
}
func runCommandConfigured(ctx context.Context, argv []string, req request, cwd string, env []string) (response, error) {
body, err := json.Marshal(req)
if err != nil {
return response{}, fmt.Errorf("marshal keyless helper request: %w", err)
}
body = append(body, '\n')
helperCtx, cancel := withExecutionTimeout(ctx)
defer cancel()
// CommandContext's default cancellation kills the helper process. This is
// important for unattended agent calls: a signer blocked on platform UI must
// not hold the caller indefinitely.
cmd := exec.CommandContext(helperCtx, argv[0], argv[1:]...)
if cwd != "" {
cmd.Dir = cwd
cmd.Env = env
}
cmd.Stdin = bytes.NewReader(body)
stdout := &limitedBuffer{limit: helperOutputLimit}
stderr := &limitedBuffer{limit: helperStderrLimit}
cmd.Stdout = stdout
cmd.Stderr = stderr
runErr := cmd.Run()
if err := helperCtx.Err(); err != nil {
// Never parse or include helper output on cancellation. A partially written
// response may contain a client assertion or other credential material.
if errors.Is(err, context.DeadlineExceeded) {
return response{}, fmt.Errorf("keyless helper timed out: %w", context.DeadlineExceeded)
}
return response{}, fmt.Errorf("keyless helper canceled: %w", err)
}
var resp response
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
if runErr != nil {
return response{}, helperRunError(runErr, stderr.String())
}
return response{}, fmt.Errorf("keyless helper produced invalid JSON: %w", err)
}
if runErr != nil && resp.Error == nil {
return response{}, helperRunError(runErr, stderr.String())
}
return resp, nil
}
func providerEnvironment(homeOverride string) []string {
// Signer implementations use OS facilities and must not inherit language
// runtime/proxy/library injection variables. HOME/TMPDIR/SystemRoot are the
// minimal cross-platform values currently needed by supported backends.
keep := map[string]bool{"HOME": true, "TMPDIR": true, "TEMP": true, "TMP": true, "SystemRoot": true, "WINDIR": true}
var env []string
for _, entry := range os.Environ() {
name := entry
if idx := strings.IndexByte(entry, '='); idx >= 0 {
name = entry[:idx]
}
if keep[name] && !(name == "HOME" && homeOverride != "") {
env = append(env, entry)
}
}
if homeOverride != "" {
env = append(env, "HOME="+homeOverride)
}
return env
}
func withExecutionTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, helperExecutionTimeout)
}
func helperRunError(runErr error, stderr string) error {
if errors.Is(runErr, os.ErrNotExist) {
return fmt.Errorf("keyless helper executable no longer exists; repair or reinstall the OpenClaw Feishu plugin: %w", runErr)
}
if strings.TrimSpace(stderr) != "" {
return fmt.Errorf("keyless helper failed: %w (stderr omitted)", runErr)
}
return fmt.Errorf("keyless helper failed: %w", runErr)
}
type limitedBuffer struct {
buf bytes.Buffer
limit int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
if b.limit <= 0 {
return len(p), nil
}
remaining := b.limit - b.buf.Len()
if remaining > 0 {
if len(p) < remaining {
remaining = len(p)
}
_, _ = b.buf.Write(p[:remaining])
}
return len(p), nil
}
func (b *limitedBuffer) Bytes() []byte {
return b.buf.Bytes()
}
func (b *limitedBuffer) String() string {
return b.buf.String()
}

View File

@@ -1,91 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylesshelper
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const helperProcessMode = "GO_WANT_KEYLESS_PROVIDER_HELPER"
func TestRunCommandProtocol(t *testing.T) {
t.Setenv(helperProcessMode, "reply")
resp, err := runCommand(context.Background(), []string{os.Args[0], "-test.run=^TestHelperProcess$"}, request{
Op: "sign-assertion", KeyRef: "key-1", ClientID: "cli_a", Audience: "aud",
})
if err != nil {
t.Fatal(err)
}
if !resp.OK || resp.ClientAssertion != "helper.jwt" {
t.Fatalf("response = %#v", resp)
}
}
func TestRunCommandTimeoutDoesNotLeakOutput(t *testing.T) {
t.Setenv(helperProcessMode, "hang")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := runCommand(ctx, []string{os.Args[0], "-test.run=^TestHelperProcess$"}, request{Op: "pubkey", KeyRef: "key-1"})
if !errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "secret.jwt") {
t.Fatalf("error = %v", err)
}
}
func TestProviderCommandRechecksDigestBeforeSpawn(t *testing.T) {
root := t.TempDir()
binary := filepath.Join(root, "signer")
if err := os.WriteFile(binary, []byte("first"), 0700); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("first"))
command, err := NewProviderCommand(binary, root, "", hex.EncodeToString(digest[:]))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(binary, []byte("second"), 0700); err != nil {
t.Fatal(err)
}
if err := command.Probe(context.Background(), "key-1"); err == nil || !strings.Contains(err.Error(), "digest changed") {
t.Fatalf("Probe error = %v", err)
}
}
func TestProviderEnvironmentOverridesOnlyPinnedHome(t *testing.T) {
t.Setenv("HOME", "/original")
t.Setenv("HTTPS_PROXY", "http://untrusted.invalid")
env := providerEnvironment("/isolated")
joined := strings.Join(env, "\n")
if !strings.Contains(joined, "HOME=/isolated") || strings.Contains(joined, "HOME=/original") || strings.Contains(joined, "HTTPS_PROXY") {
t.Fatalf("provider environment = %q", joined)
}
}
func TestHelperProcess(t *testing.T) {
switch os.Getenv(helperProcessMode) {
case "reply":
var req request
if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
os.Exit(2)
}
_ = json.NewEncoder(os.Stdout).Encode(response{
OK: true, ClientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ClientAssertion: "helper.jwt",
})
os.Exit(0)
case "hang":
_, _ = io.WriteString(os.Stdout, `{"ok":true,"client_assertion":"secret.jwt"`)
for {
time.Sleep(time.Hour)
}
}
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin || linux
package keylessprovider
import (
"context"
"os/exec"
)
func newOpenClawInspectCommand(ctx context.Context, executable string, env []string) (*exec.Cmd, error) {
cmd := exec.CommandContext(ctx, executable, "plugins", "inspect", pluginID, "--json")
cmd.Env = env
return cmd, nil
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package keylessprovider
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"syscall"
"golang.org/x/sys/windows"
)
const inspectLauncherEnv = "LARK_CLI_OPENCLAW_INSPECT_LAUNCHER"
// newOpenClawInspectCommand runs native launchers directly and npm's .cmd/.bat
// launchers through the OS-owned command interpreter. The launcher path travels
// in a dedicated environment variable and is expanded once inside a quoted
// command, while delayed expansion is disabled. This avoids interpolating a
// user-controlled path into cmd.exe syntax.
func newOpenClawInspectCommand(ctx context.Context, executable string, env []string) (*exec.Cmd, error) {
ext := strings.ToLower(filepath.Ext(executable))
if ext == ".exe" || ext == ".com" {
cmd := exec.CommandContext(ctx, executable, "plugins", "inspect", pluginID, "--json")
cmd.Env = env
return cmd, nil
}
if ext != ".cmd" && ext != ".bat" {
return nil, fmt.Errorf("unsupported Windows OpenClaw launcher extension %q", ext)
}
systemDir, err := windows.GetSystemDirectory()
if err != nil {
return nil, fmt.Errorf("resolve Windows system directory: %w", err)
}
commandInterpreter := filepath.Join(systemDir, "cmd.exe")
cmd := exec.CommandContext(ctx, commandInterpreter)
cmd.Args = nil
cmd.Env = append(append([]string(nil), env...), inspectLauncherEnv+"="+executable)
cmd.SysProcAttr = &syscall.SysProcAttr{
CmdLine: `/d /q /s /v:off /c ""%` + inspectLauncherEnv + `%" plugins inspect ` + pluginID + ` --json"`,
}
return cmd, nil
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package keylessprovider
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestNewOpenClawInspectCommand_WindowsBatchUsesSystemCmdWithoutPathInterpolation(t *testing.T) {
launcher := `C:\Users\A B&(team)%literal%\openclaw.cmd`
cmd, err := newOpenClawInspectCommand(context.Background(), launcher, []string{"PATH=C:\\Windows\\System32"})
if err != nil {
t.Fatal(err)
}
if !strings.EqualFold(filepath.Base(cmd.Path), "cmd.exe") {
t.Fatalf("command path = %q, want System32 cmd.exe", cmd.Path)
}
if cmd.SysProcAttr == nil || !strings.Contains(cmd.SysProcAttr.CmdLine, "/d /q /s /v:off /c") {
t.Fatalf("cmd line = %#v", cmd.SysProcAttr)
}
if strings.Contains(cmd.SysProcAttr.CmdLine, launcher) {
t.Fatalf("launcher path was interpolated into cmd syntax: %q", cmd.SysProcAttr.CmdLine)
}
if !strings.Contains(strings.Join(cmd.Env, "\n"), inspectLauncherEnv+"="+launcher) {
t.Fatalf("launcher environment missing: %q", cmd.Env)
}
}
func TestNewOpenClawInspectCommand_WindowsNativeExecutableIsDirect(t *testing.T) {
launcher := `C:\Program Files\OpenClaw\openclaw.exe`
cmd, err := newOpenClawInspectCommand(context.Background(), launcher, []string{"TEMP=C:\\Temp"})
if err != nil {
t.Fatal(err)
}
if cmd.Path != launcher || cmd.SysProcAttr != nil {
t.Fatalf("native command = path %q sys %#v", cmd.Path, cmd.SysProcAttr)
}
wantArgs := []string{launcher, "plugins", "inspect", pluginID, "--json"}
if strings.Join(cmd.Args, "\x00") != strings.Join(wantArgs, "\x00") {
t.Fatalf("args = %#v, want %#v", cmd.Args, wantArgs)
}
}
func TestNewOpenClawInspectCommand_WindowsRejectsUnknownLauncher(t *testing.T) {
if _, err := newOpenClawInspectCommand(context.Background(), `C:\\OpenClaw\\openclaw.ps1`, nil); err == nil {
t.Fatal("PowerShell launcher unexpectedly accepted")
}
}

View File

@@ -1,155 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylessprovider
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
providerManifestFormatVersion = 1
providerManifestFileName = "signing-providers.json"
)
type providerManifest struct {
Version int `json:"version"`
Providers map[string]providerManifestEntry `json:"providers"`
}
type providerManifestEntry struct {
PluginID string `json:"pluginId"`
PluginPackage string `json:"pluginPackage"`
SignerPackage string `json:"signerPackage"`
StateDir string `json:"stateDir"`
PluginDir string `json:"pluginDir"`
PackageDir string `json:"packageDir"`
BinaryPath string `json:"binaryPath"`
PackageVersion string `json:"packageVersion"`
OS string `json:"os"`
Arch string `json:"arch"`
SHA256 string `json:"sha256"`
PackageSize int64 `json:"packageSize"`
PackageModTimeNS int64 `json:"packageModTimeNs"`
BinarySize int64 `json:"binarySize"`
BinaryModTimeNS int64 `json:"binaryModTimeNs"`
}
var providerManifestMu sync.Mutex
func providerManifestPath() string {
return filepath.Join(core.GetBaseConfigDir(), providerManifestFileName)
}
// resolveFromProviderManifest uses the global manifest only as a location
// index. resolvePackage revalidates the full path, ownership, permissions,
// package metadata, and binary digest before a cached location can be used.
func resolveFromProviderManifest(stateDir, goos, goarch string) (resolvedProvider, bool) {
manifest, err := readProviderManifest()
if err != nil || manifest.Version != providerManifestFormatVersion {
return resolvedProvider{}, false
}
entry, ok := manifest.Providers[ProviderID]
if !ok || entry.PluginID != pluginID || entry.PluginPackage != pluginPackageName ||
entry.StateDir != stateDir || entry.OS != goos || entry.Arch != goarch {
return resolvedProvider{}, false
}
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return resolvedProvider{}, false
}
if !allowedPackageLocation(entry.PluginDir, entry.PackageDir, spec, goos) {
return resolvedProvider{}, false
}
resolved, err := resolvePackage(stateDir, entry.PluginDir, entry.PackageDir, spec, goos)
if err != nil {
return resolvedProvider{}, false
}
if entry.SignerPackage != spec.name ||
!samePath(resolved.binaryPath, entry.BinaryPath, goos) ||
resolved.packageVersion != entry.PackageVersion ||
resolved.digest != entry.SHA256 ||
resolved.packageSize != entry.PackageSize ||
resolved.packageModTimeNS != entry.PackageModTimeNS ||
resolved.binarySize != entry.BinarySize ||
resolved.binaryModTimeNS != entry.BinaryModTimeNS {
return resolvedProvider{}, false
}
return resolved, true
}
func readProviderManifest() (providerManifest, error) {
path := providerManifestPath()
if err := validateProviderObject(path, false); err != nil {
return providerManifest{}, err
}
data, err := readMetadata(path)
if err != nil {
return providerManifest{}, err
}
var manifest providerManifest
if err := decodeJSONObject(data, &manifest); err != nil {
return providerManifest{}, err
}
if manifest.Providers == nil {
manifest.Providers = make(map[string]providerManifestEntry)
}
return manifest, nil
}
func saveProviderManifest(resolved resolvedProvider, goos, goarch string) error {
providerManifestMu.Lock()
defer providerManifestMu.Unlock()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return err
}
baseDir := core.GetBaseConfigDir()
if err := vfs.MkdirAll(baseDir, 0700); err != nil {
return err
}
if err := validateProviderObject(baseDir, true); err != nil {
return err
}
manifest, err := readProviderManifest()
if err != nil || manifest.Version != providerManifestFormatVersion {
manifest = providerManifest{
Version: providerManifestFormatVersion,
Providers: make(map[string]providerManifestEntry),
}
}
if manifest.Providers == nil {
manifest.Providers = make(map[string]providerManifestEntry)
}
manifest.Providers[ProviderID] = providerManifestEntry{
PluginID: pluginID,
PluginPackage: pluginPackageName,
SignerPackage: spec.name,
StateDir: resolved.stateDir,
PluginDir: resolved.pluginDir,
PackageDir: resolved.packageDir,
BinaryPath: resolved.binaryPath,
PackageVersion: resolved.packageVersion,
OS: goos,
Arch: goarch,
SHA256: resolved.digest,
PackageSize: resolved.packageSize,
PackageModTimeNS: resolved.packageModTimeNS,
BinarySize: resolved.binarySize,
BinaryModTimeNS: resolved.binaryModTimeNS,
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return validate.AtomicWrite(providerManifestPath(), append(data, '\n'), os.FileMode(0600))
}

View File

@@ -1,493 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylessprovider
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
)
func TestResolve_ManifestMissPersistsAndHitSkipsInspect(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if command, err := Resolve(context.Background(), ProviderID); err != nil || command == nil {
t.Fatalf("first Resolve = %v, %v", command, err)
}
if calls.Load() != 1 {
t.Fatalf("inspect calls after miss = %d, want 1", calls.Load())
}
path := filepath.Join(configDir, providerManifestFileName)
manifestData, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var manifest providerManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
t.Fatalf("manifest is not valid JSON: %v", err)
}
entry := manifest.Providers[ProviderID]
if manifest.Version != providerManifestFormatVersion || entry.PackageDir != fx.packageDir ||
entry.BinaryPath != fx.binary || entry.SHA256 != fx.binaryDigest || entry.PackageVersion != "1.2.3" {
t.Fatalf("persisted manifest = %#v", manifest)
}
if runtime.GOOS != "windows" {
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("manifest permissions = %v, %v", info, err)
}
}
if command, err := Resolve(context.Background(), ProviderID); err != nil || command == nil {
t.Fatalf("cached Resolve = %v, %v", command, err)
}
if calls.Load() != 1 {
t.Fatalf("manifest hit restarted inspect; calls = %d", calls.Load())
}
}
func TestResolve_ManifestMutationRefreshes(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
changed := []byte("TEST optional-package signer") // same length, different digest
if info, err := os.Stat(fx.binary); err != nil || info.Size() != int64(len(changed)) {
t.Fatalf("fixture length precondition failed: %v, %v", info, err)
}
if err := os.WriteFile(fx.binary, changed, 0700); err != nil {
t.Fatal(err)
}
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("inspect calls after binary mutation = %d, want 2", calls.Load())
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
entry := manifest.Providers[ProviderID]
if entry.SHA256 == fx.binaryDigest {
t.Fatal("manifest retained the stale signer digest")
}
}
func TestRefresh_BypassesValidManifest(t *testing.T) {
useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
var current = oldFx
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
current = newFx
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("normal Resolve should retain valid binding; calls = %d", calls.Load())
}
if _, err := Refresh(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("Refresh did not inspect; calls = %d", calls.Load())
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir {
t.Fatalf("refreshed packageDir = %q, want %q", got, newFx.packageDir)
}
}
func TestPrepareRefresh_AbandonedCommitPreservesManifestBytes(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
current := oldFx
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(configDir, providerManifestFileName)
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
current = newFx
command, commit, err := PrepareRefresh(context.Background(), ProviderID)
if err != nil || command == nil || commit == nil {
t.Fatalf("PrepareRefresh = %v, commit %v, %v", command, commit != nil, err)
}
afterPrepare, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(afterPrepare, before) {
t.Fatal("PrepareRefresh changed the manifest before its commit callback ran")
}
const committers = 8
errs := make(chan error, committers)
var wg sync.WaitGroup
for i := 0; i < committers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errs <- commit()
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir {
t.Fatalf("committed packageDir = %q, want %q", got, newFx.packageDir)
}
}
func TestPrepareRefresh_CommitReturnsFirstErrorWithoutRetry(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir)), nil
})
_, commit, err := PrepareRefresh(context.Background(), ProviderID)
if err != nil || commit == nil {
t.Fatalf("PrepareRefresh commit %v, error %v", commit != nil, err)
}
if err := os.RemoveAll(configDir); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configDir, []byte("block manifest directory"), 0600); err != nil {
t.Fatal(err)
}
firstErr := commit()
if firstErr == nil {
t.Fatal("first commit unexpectedly succeeded")
}
if err := os.Remove(configDir); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(configDir, 0700); err != nil {
t.Fatal(err)
}
secondErr := commit()
if secondErr != firstErr {
t.Fatalf("second commit error = %v; want cached first error %v", secondErr, firstErr)
}
if _, err := os.Stat(filepath.Join(configDir, providerManifestFileName)); !os.IsNotExist(err) {
t.Fatalf("second commit retried manifest write: %v", err)
}
}
func TestPrepareRefresh_CommitRejectsSameStampBinaryMutation(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
current := oldFx
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return marshalInspectDocument(t, inspectPluginDocument(current, pluginID, current.packageDir)), nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(configDir, providerManifestFileName)
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
current = newFx
_, commit, err := PrepareRefresh(context.Background(), ProviderID)
if err != nil || commit == nil {
t.Fatalf("PrepareRefresh commit %v, error %v", commit != nil, err)
}
info, err := os.Stat(newFx.binary)
if err != nil {
t.Fatal(err)
}
changed := []byte("TEST optional-package signer")
if info.Size() != int64(len(changed)) {
t.Fatalf("fixture size = %d, mutated size = %d", info.Size(), len(changed))
}
if err := os.WriteFile(newFx.binary, changed, 0700); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(newFx.binary, info.ModTime(), info.ModTime()); err != nil {
t.Fatal(err)
}
mutatedInfo, err := os.Stat(newFx.binary)
if err != nil {
t.Fatal(err)
}
if mutatedInfo.Size() != info.Size() || !mutatedInfo.ModTime().Equal(info.ModTime()) {
t.Fatalf("mutation did not preserve size/mtime: before %d/%v, after %d/%v",
info.Size(), info.ModTime(), mutatedInfo.Size(), mutatedInfo.ModTime())
}
if err := commit(); err == nil {
t.Fatal("commit accepted a signer binary changed after PrepareRefresh")
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, before) {
t.Fatal("failed commit changed the previous manifest")
}
}
func TestRefresh_BypassesLiveInspectResultCache(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test fixture uses a POSIX shell executable")
}
useIsolatedProviderManifest(t)
oldFx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
newFx := newManagedPackageFixtureInState(t, oldFx.stateDir, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, oldFx.stateDir)
t.Setenv(openClawConfigEnv, "")
inspectOutput := filepath.Join(oldFx.stateDir, "inspect-output.json")
writeInspectOutput := func(fx optionalPackageFixture) {
t.Helper()
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
if err := os.WriteFile(inspectOutput, data, 0600); err != nil {
t.Fatal(err)
}
}
writeInspectOutput(oldFx)
binDir := t.TempDir()
openClawPath := filepath.Join(binDir, "openclaw")
const script = "#!/bin/sh\n/bin/cat \"$OPENCLAW_STATE_DIR/inspect-output.json\"\n"
if err := os.WriteFile(openClawPath, []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir)
previousCached, previousFresh := runOpenClawInspect, runOpenClawInspectFresh
runOpenClawInspect, runOpenClawInspectFresh = executeOpenClawInspect, executeOpenClawInspectFresh
t.Cleanup(func() {
runOpenClawInspect, runOpenClawInspectFresh = previousCached, previousFresh
})
// This normal resolution populates the real process cache with oldFx. The
// cache key is unchanged when only inspect-output.json is replaced.
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
writeInspectOutput(newFx)
cachedData, err := executeOpenClawInspect(context.Background(), oldFx.stateDir)
if err != nil {
t.Fatal(err)
}
cachedPlugin, err := decodeInspectedPlugin(cachedData)
if err != nil {
t.Fatal(err)
}
if cachedPlugin.RootDir != oldFx.pluginDir {
t.Fatalf("cache precondition failed: rootDir = %q, want old %q", cachedPlugin.RootDir, oldFx.pluginDir)
}
if _, err := Refresh(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
manifest, err := readProviderManifest()
if err != nil {
t.Fatal(err)
}
if got := manifest.Providers[ProviderID].PackageDir; got != newFx.packageDir {
t.Fatalf("Refresh reused cached packageDir %q; want fresh %q", got, newFx.packageDir)
}
}
func TestResolve_CorruptManifestSelfHeals(t *testing.T) {
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
if err := os.WriteFile(filepath.Join(configDir, providerManifestFileName), []byte(`{"version":`), 0600); err != nil {
t.Fatal(err)
}
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("inspect calls = %d, want 1", calls.Load())
}
if _, err := readProviderManifest(); err != nil {
t.Fatalf("manifest was not repaired: %v", err)
}
}
func TestResolve_UnsafeManifestIsIgnoredAndReplaced(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX mode and symlink test")
}
configDir := useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
var calls atomic.Int32
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
calls.Add(1)
return data, nil
})
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
path := filepath.Join(configDir, providerManifestFileName)
if err := os.Chmod(path, 0666); err != nil {
t.Fatal(err)
}
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("unsafe mode did not trigger discovery; calls = %d", calls.Load())
}
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("repaired manifest permissions = %v, %v", info, err)
}
target := filepath.Join(t.TempDir(), "must-not-overwrite.json")
const targetContents = "outside"
if err := os.WriteFile(target, []byte(targetContents), 0600); err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, path); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if calls.Load() != 3 {
t.Fatalf("manifest symlink did not trigger discovery; calls = %d", calls.Load())
}
if info, err := os.Lstat(path); err != nil || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("manifest symlink was not safely replaced: %v, %v", info, err)
}
if contents, err := os.ReadFile(target); err != nil || string(contents) != targetContents {
t.Fatalf("symlink target changed: %q, %v", contents, err)
}
}
func TestResolve_InvalidManifestDoesNotFallBackToStaleCommand(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
data := marshalInspectDocument(t, inspectPluginDocument(fx, pluginID, fx.packageDir))
stubInspectFunction(t, func(context.Context, string) ([]byte, error) { return data, nil })
if _, err := Resolve(context.Background(), ProviderID); err != nil {
t.Fatal(err)
}
if err := os.Remove(fx.binary); err != nil {
t.Fatal(err)
}
stubInspectFunction(t, func(context.Context, string) ([]byte, error) {
return nil, errors.New("inspection unavailable")
})
if _, err := Resolve(context.Background(), ProviderID); err == nil {
t.Fatal("Resolve reused an invalid manifest entry")
}
}
func marshalInspectDocument(t *testing.T, document any) []byte {
t.Helper()
data, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
return data
}
func stubInspectFunction(t *testing.T, fn func(context.Context, string) ([]byte, error)) {
t.Helper()
previousCached, previousFresh := runOpenClawInspect, runOpenClawInspectFresh
runOpenClawInspect, runOpenClawInspectFresh = fn, fn
t.Cleanup(func() {
runOpenClawInspect, runOpenClawInspectFresh = previousCached, previousFresh
})
}
func newManagedPackageFixtureInState(t *testing.T, stateDir, goos, goarch string, hoisted bool) optionalPackageFixture {
t.Helper()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
t.Skipf("host platform has no optional package: %v", err)
}
projectDir := filepath.Join(stateDir, "npm", "projects", "larksuite-openclaw-lark-next-generation")
nodeModules := filepath.Join(projectDir, "node_modules")
pluginDir := filepath.Join(nodeModules, signerPackageScope, pluginPackageName[len(signerPackageScope)+1:])
packageNodeModules := filepath.Join(pluginDir, "node_modules")
if hoisted {
packageNodeModules = nodeModules
}
return writeOptionalPackageFixture(t, optionalPackageFixture{
stateDir: stateDir, projectDir: projectDir, pluginDir: pluginDir,
packageDir: signerPackageUnder(packageNodeModules, spec), spec: spec,
})
}

View File

@@ -1,858 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keylessprovider resolves the fixed keyless signer optional dependency
// installed with the OpenClaw Feishu plugin. Application config contains only
// the logical provider ID and keyRef; executable paths and argv are never read
// from application config, environment variables, or PATH.
package keylessprovider
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keylesshelper"
"github.com/larksuite/cli/internal/vfs"
"golang.org/x/sync/singleflight"
)
const (
ProviderID = core.KeylessProviderLarkSuite
openClawStateDirEnv = "OPENCLAW_STATE_DIR"
openClawHomeEnv = "OPENCLAW_HOME"
openClawConfigEnv = "OPENCLAW_CONFIG_PATH"
openClawDirName = ".openclaw"
pluginID = "openclaw-lark"
pluginPackageName = "@larksuite/openclaw-lark"
signerPackageScope = "@larksuite"
signerBinaryBase = "lark-keyless-signer"
metadataMaxBytes = 64 << 10
binaryMaxBytes = 512 << 20
inspectStdoutLimit = 4 << 20
inspectStderrLimit = 64 << 10
inspectCommandLimit = 30 * time.Second
inspectCacheTTL = 3 * time.Second
)
var semver = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
type platformPackage struct {
name string
npmOS string
npmCPU string
binaryName string
}
type packageManifest struct {
Name string `json:"name"`
Version string `json:"version"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
}
type resolvedProvider struct {
stateDir string
pluginDir string
packageDir string
binaryPath string
packageVersion string
packageSize int64
packageModTimeNS int64
binarySize int64
binaryModTimeNS int64
digest string
}
type inspectDependency struct {
Name string `json:"name"`
Installed bool `json:"installed"`
Optional bool `json:"optional"`
ResolvedPath string `json:"resolvedPath"`
}
type inspectedPlugin struct {
ID string `json:"id"`
Name string `json:"name"`
PackageName string `json:"packageName"`
RootDir string `json:"rootDir"`
Status string `json:"status"`
DependencyStatus struct {
OptionalDependencies []inspectDependency `json:"optionalDependencies"`
} `json:"dependencyStatus"`
}
type inspectUnavailableError struct{ cause error }
func (e *inspectUnavailableError) Error() string { return e.cause.Error() }
func (e *inspectUnavailableError) Unwrap() error { return e.cause }
var (
runOpenClawInspect = executeOpenClawInspect
runOpenClawInspectFresh = executeOpenClawInspectFresh
)
type inspectCacheEntry struct {
data []byte
expiresAt time.Time
}
// inspectResultCache caches only OpenClaw's discovery document. Callers still
// validate the complete directory tree, package metadata, binary mode, and
// binary digest after every lookup, and keylesshelper re-hashes before exec.
// The short TTL removes duplicate OpenClaw startups within one CLI process
// without persisting an executable path across processes or plugin upgrades.
type inspectResultCache struct {
mu sync.Mutex
entries map[string]inspectCacheEntry
flights singleflight.Group
}
var openClawInspectCache inspectResultCache
// Resolve returns a freshly verified command for one provider operation. A
// global manifest avoids restarting OpenClaw when the previously bound package
// is unchanged; the package tree and binary digest are still revalidated.
func Resolve(ctx context.Context, provider string) (*keylesshelper.Command, error) {
command, resolved, err := resolve(ctx, provider, false)
if err != nil {
return nil, err
}
if resolved != nil {
// The manifest is a rebuildable performance index, not an
// authentication source. Runtime resolution remains available if its
// best-effort cache write fails.
_ = saveProviderManifest(*resolved, runtime.GOOS, runtime.GOARCH)
}
return command, nil
}
// PrepareRefresh bypasses both the global manifest and the short-lived
// in-process OpenClaw inspection cache. It returns a freshly verified command
// and a deferred manifest commit. Callers that validate the signer before
// changing binding state must invoke commit only after that validation
// succeeds; abandoning the callback leaves the existing manifest unchanged.
func PrepareRefresh(ctx context.Context, provider string) (*keylesshelper.Command, func() error, error) {
command, resolved, err := resolve(ctx, provider, true)
if err != nil {
return nil, nil, err
}
if resolved == nil {
return command, func() error { return nil }, nil
}
candidate := *resolved
var once sync.Once
var commitErr error
commit := func() error {
once.Do(func() {
commitErr = revalidatePreparedProvider(candidate, runtime.GOOS, runtime.GOARCH)
if commitErr != nil {
return
}
commitErr = saveProviderManifest(candidate, runtime.GOOS, runtime.GOARCH)
})
return commitErr
}
return command, commit, nil
}
// revalidatePreparedProvider closes the gap between signer validation and a
// deferred manifest commit. It repeats the complete package validation and
// requires every captured property to remain identical before the candidate is
// allowed to replace the current manifest entry.
func revalidatePreparedProvider(candidate resolvedProvider, goos, goarch string) error {
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return err
}
verified, err := resolvePackage(candidate.stateDir, candidate.pluginDir, candidate.packageDir, spec, goos)
if err != nil {
return fmt.Errorf("revalidate prepared keyless signer: %w", err)
}
if verified != candidate {
return fmt.Errorf("prepared keyless signer changed before manifest commit")
}
return nil
}
// Refresh bypasses the global manifest and both discovers and records the
// signer reported by the current OpenClaw installation. Transactional bind
// flows should use PrepareRefresh so a failed signer validation cannot replace
// a previously working manifest entry.
func Refresh(ctx context.Context, provider string) (*keylesshelper.Command, error) {
command, commit, err := PrepareRefresh(ctx, provider)
if err != nil {
return nil, err
}
if commit != nil {
if err := commit(); err != nil {
return nil, fmt.Errorf("persist refreshed keyless signer manifest: %w", err)
}
}
return command, nil
}
// resolve returns a non-nil resolvedProvider only for a newly discovered
// package. A manifest hit has already been persisted and therefore returns a
// nil resolvedProvider.
func resolve(ctx context.Context, provider string, forceRefresh bool) (*keylesshelper.Command, *resolvedProvider, error) {
if ctx == nil {
ctx = context.Background()
}
provider = strings.TrimSpace(provider)
if provider == "" {
return nil, nil, nil
}
if provider != ProviderID {
return nil, nil, fmt.Errorf("unknown keyless signer provider %q", provider)
}
stateDir, err := openClawStateDir()
if err != nil {
return nil, nil, fmt.Errorf("resolve OpenClaw state directory: %w", err)
}
if !forceRefresh {
resolved, manifestHit := resolveFromProviderManifest(stateDir, runtime.GOOS, runtime.GOARCH)
if manifestHit {
command, err := keylesshelper.NewProviderCommand(resolved.binaryPath, resolved.packageDir, "", resolved.digest)
return command, nil, err
}
}
inspectResolver := resolveFromInspect
if forceRefresh {
inspectResolver = resolveFromInspectFresh
}
resolved, inspectErr := inspectResolver(ctx, stateDir, runtime.GOOS, runtime.GOARCH)
if inspectErr != nil {
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("%s provider inspection canceled: %w", ProviderID, err)
}
var unavailable *inspectUnavailableError
if !errors.As(inspectErr, &unavailable) {
return nil, nil, fmt.Errorf("%s provider is unavailable: %w", ProviderID, inspectErr)
}
// Older/local OpenClaw installations may not expose `plugins inspect`.
// The only fallback is the fixed extension-local package path; managed
// npm project directories are never scanned or guessed.
resolved, err = resolveFromStateDir(stateDir, runtime.GOOS, runtime.GOARCH)
if err != nil {
return nil, nil, fmt.Errorf("%s provider is unavailable: OpenClaw inspect failed (%w); fixed extension fallback failed: %w", ProviderID, inspectErr, err)
}
}
// An empty home override deliberately preserves the recipient user's normal
// HOME. The helper still supplies a minimal environment and never inherits
// PATH, proxy, language-runtime, or loader injection variables.
command, err := keylesshelper.NewProviderCommand(resolved.binaryPath, resolved.packageDir, "", resolved.digest)
if err != nil {
return nil, nil, err
}
return command, &resolved, nil
}
func resolveFromStateDir(stateDir, goos, goarch string) (resolvedProvider, error) {
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return resolvedProvider{}, err
}
stateDir, err = cleanAbsolutePath(stateDir)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid OpenClaw state directory: %w", err)
}
pluginDir := filepath.Join(stateDir, "extensions", pluginID)
packageDir := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec)
return resolvePackage(stateDir, pluginDir, packageDir, spec, goos)
}
func resolveFromInspect(ctx context.Context, stateDir, goos, goarch string) (resolvedProvider, error) {
return resolveFromInspectUsing(ctx, stateDir, goos, goarch, runOpenClawInspect)
}
func resolveFromInspectFresh(ctx context.Context, stateDir, goos, goarch string) (resolvedProvider, error) {
return resolveFromInspectUsing(ctx, stateDir, goos, goarch, runOpenClawInspectFresh)
}
func resolveFromInspectUsing(
ctx context.Context,
stateDir, goos, goarch string,
inspect func(context.Context, string) ([]byte, error),
) (resolvedProvider, error) {
spec, err := signerPackageFor(goos, goarch)
if err != nil {
return resolvedProvider{}, err
}
stateDir, err = cleanAbsolutePath(stateDir)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid OpenClaw state directory: %w", err)
}
data, err := inspect(ctx, stateDir)
if err != nil {
return resolvedProvider{}, err
}
plugin, err := decodeInspectedPlugin(data)
if err != nil {
return resolvedProvider{}, fmt.Errorf("parse OpenClaw plugin inspection: %w", err)
}
if plugin.ID != pluginID {
return resolvedProvider{}, fmt.Errorf("OpenClaw inspected unexpected plugin %q", plugin.ID)
}
if plugin.PackageName != "" && plugin.PackageName != pluginPackageName {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin package %q does not match %q", plugin.PackageName, pluginPackageName)
}
if plugin.Name != "" && plugin.Name != pluginPackageName && plugin.Name != "Feishu" {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin name %q is not recognized", plugin.Name)
}
if plugin.Status != "" && plugin.Status != "loaded" {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin is not loaded (status %q)", plugin.Status)
}
pluginDir, err := cleanAbsolutePath(plugin.RootDir)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid inspected plugin root: %w", err)
}
if err := ensureWithin(stateDir, pluginDir); err != nil {
return resolvedProvider{}, fmt.Errorf("inspected plugin root is outside OpenClaw state: %w", err)
}
var dependency *inspectDependency
for i := range plugin.DependencyStatus.OptionalDependencies {
candidate := &plugin.DependencyStatus.OptionalDependencies[i]
if candidate.Name != spec.name {
continue
}
if dependency != nil {
return resolvedProvider{}, fmt.Errorf("OpenClaw inspection contains duplicate signer dependency %q", spec.name)
}
dependency = candidate
}
if dependency == nil || !dependency.Optional || !dependency.Installed || strings.TrimSpace(dependency.ResolvedPath) == "" {
return resolvedProvider{}, fmt.Errorf("OpenClaw plugin optional signer dependency %q is not installed", spec.name)
}
packageDir, err := cleanAbsolutePath(dependency.ResolvedPath)
if err != nil {
return resolvedProvider{}, fmt.Errorf("invalid inspected signer package path: %w", err)
}
if err := ensureWithin(stateDir, packageDir); err != nil {
return resolvedProvider{}, fmt.Errorf("inspected signer package is outside OpenClaw state: %w", err)
}
if !allowedPackageLocation(pluginDir, packageDir, spec, goos) {
return resolvedProvider{}, fmt.Errorf("inspected signer package is neither plugin-local nor managed-project-hoisted")
}
return resolvePackage(stateDir, pluginDir, packageDir, spec, goos)
}
func resolvePackage(stateDir, pluginDir, packageDir string, spec platformPackage, goos string) (resolvedProvider, error) {
packageJSON := filepath.Join(packageDir, "package.json")
binDir := filepath.Join(packageDir, "bin")
binaryPath := filepath.Join(binDir, spec.binaryName)
for _, dir := range []string{pluginDir, packageDir, binDir} {
if err := validateDirectoryTree(stateDir, dir); err != nil {
return resolvedProvider{}, err
}
}
for _, file := range []string{packageJSON, binaryPath} {
if err := ensureWithin(stateDir, file); err != nil {
return resolvedProvider{}, err
}
if err := validateProviderObject(file, false); err != nil {
return resolvedProvider{}, err
}
}
data, err := readMetadata(packageJSON)
if err != nil {
return resolvedProvider{}, fmt.Errorf("read signer package metadata: %w", err)
}
var manifest packageManifest
if err := decodeJSONObject(data, &manifest); err != nil {
return resolvedProvider{}, fmt.Errorf("parse signer package metadata: %w", err)
}
if manifest.Name != spec.name {
return resolvedProvider{}, fmt.Errorf("signer package name %q does not match %q", manifest.Name, spec.name)
}
if !semver.MatchString(manifest.Version) {
return resolvedProvider{}, fmt.Errorf("signer package version %q is not valid semver", manifest.Version)
}
if len(manifest.OS) != 1 || manifest.OS[0] != spec.npmOS {
return resolvedProvider{}, fmt.Errorf("signer package os metadata does not match %s", spec.npmOS)
}
if len(manifest.CPU) != 1 || manifest.CPU[0] != spec.npmCPU {
return resolvedProvider{}, fmt.Errorf("signer package cpu metadata does not match %s", spec.npmCPU)
}
packageInfo, err := vfs.Lstat(packageJSON)
if err != nil {
return resolvedProvider{}, fmt.Errorf("stat signer package metadata: %w", err)
}
info, err := vfs.Lstat(binaryPath)
if err != nil {
return resolvedProvider{}, fmt.Errorf("stat signer binary: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return resolvedProvider{}, fmt.Errorf("signer binary is not a regular file")
}
if goos != "windows" && info.Mode().Perm()&0o111 == 0 {
return resolvedProvider{}, fmt.Errorf("signer binary is not executable")
}
digest, err := hashRegularFile(binaryPath, binaryMaxBytes)
if err != nil {
return resolvedProvider{}, fmt.Errorf("hash signer binary: %w", err)
}
return resolvedProvider{
stateDir: stateDir,
pluginDir: pluginDir,
packageDir: packageDir,
binaryPath: binaryPath,
packageVersion: manifest.Version,
packageSize: packageInfo.Size(),
packageModTimeNS: packageInfo.ModTime().UnixNano(),
binarySize: info.Size(),
binaryModTimeNS: info.ModTime().UnixNano(),
digest: digest,
}, nil
}
func signerPackageFor(goos, goarch string) (platformPackage, error) {
key := goos + "/" + goarch
packages := map[string]platformPackage{
"darwin/arm64": {"@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", signerBinaryBase},
"darwin/amd64": {"@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", signerBinaryBase},
"linux/arm64": {"@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", signerBinaryBase},
"linux/amd64": {"@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", signerBinaryBase},
"windows/amd64": {"@larksuite/lark-keyless-signer-win32-x64", "win32", "x64", signerBinaryBase + ".exe"},
}
if spec, ok := packages[key]; ok {
return spec, nil
}
return platformPackage{}, fmt.Errorf("OpenClaw keyless signer optional package is unsupported on %s/%s", goos, goarch)
}
func signerPackageUnder(nodeModules string, spec platformPackage) string {
return filepath.Join(nodeModules, signerPackageScope, strings.TrimPrefix(spec.name, signerPackageScope+"/"))
}
func allowedPackageLocation(pluginDir, packageDir string, spec platformPackage, goos string) bool {
pluginLocal := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec)
if samePath(pluginLocal, packageDir, goos) {
return true
}
// npm-pack managed projects may hoist the optional dependency beside the
// plugin package:
// <project>/node_modules/@larksuite/openclaw-lark
// <project>/node_modules/@larksuite/lark-keyless-signer-...
pluginNodeModules := filepath.Dir(filepath.Dir(pluginDir))
expectedPlugin := filepath.Join(pluginNodeModules, signerPackageScope, strings.TrimPrefix(pluginPackageName, signerPackageScope+"/"))
if filepath.Base(pluginNodeModules) != "node_modules" || !samePath(expectedPlugin, pluginDir, goos) {
return false
}
return samePath(signerPackageUnder(pluginNodeModules, spec), packageDir, goos)
}
func samePath(left, right, goos string) bool {
left, right = filepath.Clean(left), filepath.Clean(right)
if goos == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func decodeInspectedPlugin(data []byte) (inspectedPlugin, error) {
if len(data) == 0 || len(data) > inspectStdoutLimit {
return inspectedPlugin{}, fmt.Errorf("inspection output size is invalid")
}
var envelope struct {
Plugin json.RawMessage `json:"plugin"`
}
if err := decodeJSONObject(data, &envelope); err != nil {
return inspectedPlugin{}, err
}
var plugin inspectedPlugin
if len(envelope.Plugin) != 0 && string(envelope.Plugin) != "null" {
if err := decodeJSONObject(envelope.Plugin, &plugin); err != nil {
return inspectedPlugin{}, err
}
} else if err := decodeJSONObject(data, &plugin); err != nil {
return inspectedPlugin{}, err
}
return plugin, nil
}
func executeOpenClawInspect(ctx context.Context, stateDir string) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
executable, cacheKey, err := prepareOpenClawInspect(stateDir)
if err != nil {
return nil, err
}
return openClawInspectCache.load(ctx, cacheKey, func(loadCtx context.Context) ([]byte, error) {
return executeOpenClawInspectCommand(loadCtx, stateDir, executable)
})
}
// executeOpenClawInspectFresh deliberately skips inspectResultCache, including
// its in-flight singleflight results. Bind/repair discovery must observe the
// current plugin generation even if normal resolution inspected the same
// OpenClaw installation during the preceding few seconds.
func executeOpenClawInspectFresh(ctx context.Context, stateDir string) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
executable, _, err := prepareOpenClawInspect(stateDir)
if err != nil {
return nil, err
}
return executeOpenClawInspectCommand(ctx, stateDir, executable)
}
func prepareOpenClawInspect(stateDir string) (string, string, error) {
executable, err := exec.LookPath("openclaw")
if err != nil {
return "", "", &inspectUnavailableError{cause: fmt.Errorf("openclaw executable not found: %w", err)}
}
if !filepath.IsAbs(executable) {
cwd, cwdErr := vfs.Getwd()
if cwdErr != nil {
return "", "", fmt.Errorf("resolve current directory for openclaw executable: %w", cwdErr)
}
executable = filepath.Join(cwd, executable)
}
executable, err = vfs.EvalSymlinks(executable)
if err != nil {
return "", "", fmt.Errorf("resolve openclaw executable symlinks: %w", err)
}
executable, err = cleanAbsolutePath(executable)
if err != nil {
return "", "", fmt.Errorf("validate openclaw executable path: %w", err)
}
if err := validateInspectExecutable(executable); err != nil {
return "", "", fmt.Errorf("validate openclaw executable: %w", err)
}
info, err := vfs.Lstat(executable)
if err != nil || runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
return "", "", fmt.Errorf("openclaw executable is not executable")
}
cacheKey := strings.Join([]string{
stateDir,
executable,
fmt.Sprintf("%d", info.Size()),
fmt.Sprintf("%d", info.ModTime().UnixNano()),
strings.TrimSpace(os.Getenv(openClawConfigEnv)),
}, "\x00")
return executable, cacheKey, nil
}
func (c *inspectResultCache) load(ctx context.Context, key string, loader func(context.Context) ([]byte, error)) ([]byte, error) {
now := time.Now()
c.mu.Lock()
if entry, ok := c.entries[key]; ok && now.Before(entry.expiresAt) {
data := append([]byte(nil), entry.data...)
c.mu.Unlock()
return data, nil
}
c.mu.Unlock()
resultCh := c.flights.DoChan(key, func() (any, error) {
now := time.Now()
c.mu.Lock()
if entry, ok := c.entries[key]; ok && now.Before(entry.expiresAt) {
data := append([]byte(nil), entry.data...)
c.mu.Unlock()
return data, nil
}
c.mu.Unlock()
data, err := loader(ctx)
if err != nil {
return nil, err
}
data = append([]byte(nil), data...)
c.mu.Lock()
if c.entries == nil {
c.entries = make(map[string]inspectCacheEntry)
}
c.entries[key] = inspectCacheEntry{data: data, expiresAt: time.Now().Add(inspectCacheTTL)}
c.mu.Unlock()
return append([]byte(nil), data...), nil
})
select {
case <-ctx.Done():
return nil, ctx.Err()
case result := <-resultCh:
if result.Err != nil {
return nil, result.Err
}
data, ok := result.Val.([]byte)
if !ok {
return nil, fmt.Errorf("OpenClaw inspect cache returned an invalid result")
}
return append([]byte(nil), data...), nil
}
}
func executeOpenClawInspectCommand(ctx context.Context, stateDir, executable string) ([]byte, error) {
inspectCtx, cancel := context.WithTimeout(ctx, inspectCommandLimit)
defer cancel()
cmd, err := newOpenClawInspectCommand(inspectCtx, executable, openClawInspectEnvironment(stateDir))
if err != nil {
return nil, &inspectUnavailableError{cause: fmt.Errorf("prepare openclaw plugin inspection: %w", err)}
}
stdout := &cappedBuffer{limit: inspectStdoutLimit}
stderr := &cappedBuffer{limit: inspectStderrLimit}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
if inspectCtx.Err() != nil {
return nil, &inspectUnavailableError{cause: fmt.Errorf("openclaw plugin inspection stopped: %w", inspectCtx.Err())}
}
return nil, &inspectUnavailableError{cause: fmt.Errorf("openclaw plugin inspection failed: %w (stderr omitted)", err)}
}
if stdout.exceeded {
return nil, fmt.Errorf("openclaw plugin inspection stdout exceeds %d bytes", inspectStdoutLimit)
}
if stderr.exceeded {
return nil, fmt.Errorf("openclaw plugin inspection stderr exceeds %d bytes", inspectStderrLimit)
}
return append([]byte(nil), stdout.Bytes()...), nil
}
func openClawInspectEnvironment(stateDir string) []string {
keep := map[string]bool{
"HOME": true, "PATH": true, "PATHEXT": true, "TMPDIR": true, "TEMP": true, "TMP": true,
"LANG": true, "LC_ALL": true, "USERPROFILE": true, "SYSTEMROOT": true, "WINDIR": true,
"COMSPEC": true, "APPDATA": true, "LOCALAPPDATA": true,
"OPENCLAW_HOME": true, openClawConfigEnv: true,
}
env := make([]string, 0, len(keep)+1)
for _, entry := range os.Environ() {
name := entry
if idx := strings.IndexByte(entry, '='); idx >= 0 {
name = entry[:idx]
}
if keep[strings.ToUpper(name)] {
env = append(env, entry)
}
}
return append(env, openClawStateDirEnv+"="+stateDir)
}
type cappedBuffer struct {
bytes.Buffer
limit int
exceeded bool
}
func (b *cappedBuffer) Write(data []byte) (int, error) {
originalLen := len(data)
remaining := b.limit - b.Len()
if remaining < len(data) {
b.exceeded = true
if remaining < 0 {
remaining = 0
}
data = data[:remaining]
}
_, _ = b.Buffer.Write(data)
return originalLen, nil
}
func validateDirectoryTree(root, target string) error {
root, err := cleanAbsolutePath(root)
if err != nil {
return err
}
target, err = cleanAbsolutePath(target)
if err != nil {
return err
}
if err := ensureWithin(root, target); err != nil {
return err
}
if err := validateProviderObject(root, true); err != nil {
return err
}
rel, err := filepath.Rel(root, target)
if err != nil {
return err
}
current := root
if rel == "." {
return nil
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
if part == "" || part == "." || part == ".." {
return fmt.Errorf("provider directory tree contains invalid component %q", part)
}
current = filepath.Join(current, part)
if err := validateProviderObject(current, true); err != nil {
return err
}
}
return nil
}
func openClawStateDir() (string, error) {
if stateDir := strings.TrimSpace(os.Getenv(openClawStateDirEnv)); stateDir != "" {
return resolveOpenClawPath(stateDir)
}
if configPath := strings.TrimSpace(os.Getenv(openClawConfigEnv)); configPath != "" {
resolved, err := resolveOpenClawPath(configPath)
if err != nil {
return "", fmt.Errorf("invalid %s: %w", openClawConfigEnv, err)
}
return cleanAbsolutePath(filepath.Dir(resolved))
}
home, err := openClawEffectiveHome()
if err != nil {
return "", err
}
return cleanAbsolutePath(filepath.Join(home, openClawDirName))
}
func resolveOpenClawPath(path string) (string, error) {
home, err := openClawEffectiveHome()
if err != nil {
return "", err
}
return expandWithHomeAndClean(path, home)
}
func openClawEffectiveHome() (string, error) {
osHome, err := vfs.UserHomeDir()
if err != nil || strings.TrimSpace(osHome) == "" {
return "", fmt.Errorf("resolve user home: %w", err)
}
osHome, err = cleanAbsolutePath(filepath.Clean(osHome))
if err != nil {
return "", fmt.Errorf("invalid user home: %w", err)
}
if configured := strings.TrimSpace(os.Getenv(openClawHomeEnv)); configured != "" {
home, err := expandWithHomeAndClean(configured, osHome)
if err != nil {
return "", fmt.Errorf("invalid %s: %w", openClawHomeEnv, err)
}
return home, nil
}
return osHome, nil
}
func expandWithHomeAndClean(path, home string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
if path == "~" {
path = home
} else {
path = filepath.Join(home, path[2:])
}
} else if strings.HasPrefix(path, "~") {
return "", fmt.Errorf("named-user home expansion is not supported")
}
return cleanAbsolutePath(path)
}
func cleanAbsolutePath(path string) (string, error) {
if strings.IndexByte(path, 0) >= 0 {
return "", fmt.Errorf("path contains NUL")
}
if !filepath.IsAbs(path) {
return "", fmt.Errorf("path must be absolute")
}
clean := filepath.Clean(path)
if clean != path {
return "", fmt.Errorf("path must be clean (got %q)", path)
}
return clean, nil
}
func ensureWithin(root, path string) error {
rel, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("check signer path boundary: %w", err)
}
if rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("signer path escapes OpenClaw state directory")
}
return nil
}
func decodeJSONObject(data []byte, out any) error {
trimmed := strings.TrimSpace(string(data))
if !strings.HasPrefix(trimmed, "{") || !strings.HasSuffix(trimmed, "}") {
return fmt.Errorf("expected JSON object")
}
decoder := json.NewDecoder(strings.NewReader(trimmed))
if err := decoder.Decode(out); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
}
return err
}
return nil
}
func readMetadata(path string) ([]byte, error) {
info, err := vfs.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 2 || info.Size() > metadataMaxBytes {
return nil, fmt.Errorf("metadata size/type is invalid")
}
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
if len(data) > metadataMaxBytes {
return nil, fmt.Errorf("metadata exceeds %d bytes", metadataMaxBytes)
}
return data, nil
}
func hashRegularFile(path string, maxSize int64) (string, error) {
info, err := vfs.Lstat(path)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() <= 0 || info.Size() > maxSize {
return "", fmt.Errorf("file size/type is invalid")
}
f, err := vfs.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, io.LimitReader(f, maxSize+1))
if err != nil {
return "", err
}
if n != info.Size() || n > maxSize {
return "", fmt.Errorf("file changed while hashing")
}
return hex.EncodeToString(h.Sum(nil)), nil
}

View File

@@ -1,537 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keylessprovider
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
)
type optionalPackageFixture struct {
stateDir, projectDir, pluginDir string
packageDir, packageJSON, binDir, binary string
spec platformPackage
binaryDigest string
}
func TestSignerPackageFor(t *testing.T) {
tests := []struct {
goos, goarch, name, npmOS, npmCPU, binary string
}{
{"darwin", "arm64", "@larksuite/lark-keyless-signer-darwin-arm64", "darwin", "arm64", signerBinaryBase},
{"darwin", "amd64", "@larksuite/lark-keyless-signer-darwin-x64", "darwin", "x64", signerBinaryBase},
{"linux", "arm64", "@larksuite/lark-keyless-signer-linux-arm64", "linux", "arm64", signerBinaryBase},
{"linux", "amd64", "@larksuite/lark-keyless-signer-linux-x64", "linux", "x64", signerBinaryBase},
{"windows", "amd64", "@larksuite/lark-keyless-signer-win32-x64", "win32", "x64", signerBinaryBase + ".exe"},
}
for _, test := range tests {
t.Run(test.goos+"_"+test.goarch, func(t *testing.T) {
got, err := signerPackageFor(test.goos, test.goarch)
if err != nil {
t.Fatal(err)
}
if got.name != test.name || got.npmOS != test.npmOS || got.npmCPU != test.npmCPU || got.binaryName != test.binary {
t.Fatalf("spec = %#v", got)
}
})
}
for _, unsupported := range [][2]string{{"windows", "arm64"}, {"linux", "riscv64"}, {"freebsd", "amd64"}} {
if _, err := signerPackageFor(unsupported[0], unsupported[1]); err == nil {
t.Fatalf("%s/%s unexpectedly supported", unsupported[0], unsupported[1])
}
}
}
func TestResolveFromStateDir_OptionalPackage(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
got, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH)
if err != nil {
t.Fatal(err)
}
if got.binaryPath != fx.binary || got.packageDir != fx.packageDir || got.digest != fx.binaryDigest {
t.Fatalf("resolved = %#v, fixture = %#v", got, fx)
}
}
func TestResolve_UsesOpenClawStateDirAndFixedExtensionFallback(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
t.Setenv(openClawHomeEnv, filepath.Join(t.TempDir(), "must-not-win"))
t.Setenv("PATH", "") // force the fixed extensions fallback
command, err := Resolve(context.Background(), ProviderID)
if err != nil {
t.Fatal(err)
}
if command == nil {
t.Fatal("Resolve returned nil command")
}
}
func TestResolveFromInspect_ManagedProjectPluginLocal(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, false)
got, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, fx.packageDir))
if err != nil {
t.Fatal(err)
}
assertResolvedFixture(t, got, fx)
}
func TestResolveFromInspect_ManagedProjectHoisted(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
got, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, fx.packageDir))
if err != nil {
t.Fatal(err)
}
assertResolvedFixture(t, got, fx)
}
func TestResolveFromInspect_RejectsWrongPluginAndEscapingPaths(t *testing.T) {
t.Run("wrong plugin", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
_, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, "evil-plugin", fx.packageDir))
if err == nil || !strings.Contains(err.Error(), "unexpected plugin") {
t.Fatalf("error = %v", err)
}
})
t.Run("package outside state", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
outside := filepath.Join(t.TempDir(), "node_modules", signerPackageScope, strings.TrimPrefix(fx.spec.name, signerPackageScope+"/"))
_, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, outside))
if err == nil || !strings.Contains(err.Error(), "outside OpenClaw state") {
t.Fatalf("error = %v", err)
}
})
t.Run("unrelated package inside state", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
unrelated := signerPackageUnder(filepath.Join(fx.stateDir, "other", "node_modules"), fx.spec)
_, err := resolveFromInspectDocument(t, fx, inspectPluginDocument(fx, pluginID, unrelated))
if err == nil || !strings.Contains(err.Error(), "neither plugin-local nor managed-project-hoisted") {
t.Fatalf("error = %v", err)
}
})
t.Run("plugin outside state", func(t *testing.T) {
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
document := inspectPluginDocument(fx, pluginID, fx.packageDir)
plugin := document["plugin"].(map[string]any)
plugin["rootDir"] = filepath.Join(t.TempDir(), "openclaw-lark")
_, err := resolveFromInspectDocument(t, fx, document)
if err == nil || !strings.Contains(err.Error(), "outside OpenClaw state") {
t.Fatalf("error = %v", err)
}
})
}
func TestResolve_InspectUnavailableUsesOnlyFixedExtensionFallback(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubOpenClawInspect(t, nil, &inspectUnavailableError{cause: errors.New("not installed")})
command, err := Resolve(context.Background(), ProviderID)
if err != nil || command == nil {
t.Fatalf("Resolve = %v, %v", command, err)
}
}
func TestResolve_InspectUnavailableDoesNotScanManagedProjects(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newManagedPackageFixture(t, runtime.GOOS, runtime.GOARCH, true)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubOpenClawInspect(t, nil, &inspectUnavailableError{cause: errors.New("not installed")})
if _, err := Resolve(context.Background(), ProviderID); err == nil || !strings.Contains(err.Error(), "fixed extension fallback failed") {
t.Fatalf("error = %v", err)
}
}
func TestResolve_InvalidInspectDoesNotFallBack(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
stubOpenClawInspect(t, []byte(`{"plugin":`), nil)
if _, err := Resolve(context.Background(), ProviderID); err == nil || !strings.Contains(err.Error(), "parse OpenClaw") {
t.Fatalf("error = %v", err)
}
}
func TestResolve_InspectCancellationHonorsCallerContext(t *testing.T) {
useIsolatedProviderManifest(t)
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
t.Setenv(openClawStateDirEnv, fx.stateDir)
previous := runOpenClawInspect
runOpenClawInspect = func(ctx context.Context, _ string) ([]byte, error) {
<-ctx.Done()
return nil, &inspectUnavailableError{cause: ctx.Err()}
}
t.Cleanup(func() { runOpenClawInspect = previous })
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Resolve(ctx, ProviderID); !errors.Is(err, context.Canceled) {
t.Fatalf("Resolve error = %v, want context.Canceled", err)
}
}
func TestInspectResultCache_ShortLivedAndSingleflight(t *testing.T) {
var cache inspectResultCache
var calls atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
loader := func(context.Context) ([]byte, error) {
if calls.Add(1) == 1 {
close(started)
}
<-release
return []byte(`{"plugin":{"id":"openclaw-lark"}}`), nil
}
const callers = 8
results := make(chan []byte, callers)
errs := make(chan error, callers)
var wg sync.WaitGroup
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
data, err := cache.load(context.Background(), "same-install", loader)
results <- data
errs <- err
}()
}
<-started
close(release)
wg.Wait()
close(results)
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for data := range results {
if string(data) != `{"plugin":{"id":"openclaw-lark"}}` {
t.Fatalf("cached data = %q", data)
}
if len(data) > 0 {
data[0] = 'x'
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("loader calls = %d, want 1", got)
}
data, err := cache.load(context.Background(), "same-install", loader)
if err != nil || string(data) != `{"plugin":{"id":"openclaw-lark"}}` || calls.Load() != 1 {
t.Fatalf("cache hit = %q, %v, calls %d", data, err, calls.Load())
}
}
func TestInspectResultCache_WaiterHonorsContext(t *testing.T) {
var cache inspectResultCache
started := make(chan struct{})
release := make(chan struct{})
loader := func(context.Context) ([]byte, error) {
close(started)
<-release
return []byte(`{}`), nil
}
go func() {
_, _ = cache.load(context.Background(), "busy", loader)
}()
<-started
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := cache.load(ctx, "busy", loader); !errors.Is(err, context.Canceled) {
t.Fatalf("cache waiter error = %v, want context.Canceled", err)
}
close(release)
}
func TestInspectOutputAndEnvironmentAreBounded(t *testing.T) {
buffer := &cappedBuffer{limit: 4}
if n, err := buffer.Write([]byte("123456")); err != nil || n != 6 || !buffer.exceeded || buffer.String() != "1234" {
t.Fatalf("bounded write = n %d exceeded %v data %q err %v", n, buffer.exceeded, buffer.String(), err)
}
t.Setenv("HOME", "/safe-home")
t.Setenv("NODE_OPTIONS", "--require=/must-not-load")
t.Setenv(openClawStateDirEnv, "/must-not-win")
env := strings.Join(openClawInspectEnvironment("/selected-state"), "\n")
if !strings.Contains(env, "HOME=/safe-home") || !strings.Contains(env, openClawStateDirEnv+"=/selected-state") ||
strings.Contains(env, "NODE_OPTIONS") || strings.Contains(env, openClawStateDirEnv+"=/must-not-win") {
t.Fatalf("inspect environment = %q", env)
}
}
func TestResolve_UnknownProviderFailsClosed(t *testing.T) {
useIsolatedProviderManifest(t)
if _, err := Resolve(context.Background(), "evil.provider"); err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatalf("error = %v", err)
}
}
func useIsolatedProviderManifest(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
return dir
}
func TestOpenClawStateDirPriorityAndExpansion(t *testing.T) {
home := t.TempDir()
state := filepath.Join(t.TempDir(), "custom state")
t.Setenv("HOME", home)
t.Setenv(openClawConfigEnv, "")
t.Setenv(openClawHomeEnv, filepath.Join(t.TempDir(), "must-not-win"))
t.Setenv(openClawStateDirEnv, state)
if got, err := openClawStateDir(); err != nil || got != state {
t.Fatalf("state dir = %q, %v", got, err)
}
t.Setenv(openClawStateDirEnv, "")
openClawHome := filepath.Join(t.TempDir(), "openclaw home")
t.Setenv(openClawHomeEnv, openClawHome)
if got, err := openClawStateDir(); err != nil || got != filepath.Join(openClawHome, openClawDirName) {
t.Fatalf("OPENCLAW_HOME state dir = %q, %v", got, err)
}
t.Setenv(openClawHomeEnv, "")
t.Setenv(openClawStateDirEnv, "~/custom-openclaw")
if got, err := openClawStateDir(); err != nil || got != filepath.Join(home, "custom-openclaw") {
t.Fatalf("expanded state dir = %q, %v", got, err)
}
}
func TestOpenClawStateDir_ConfigPathPrecedesHome(t *testing.T) {
osHome := t.TempDir()
openClawHome := filepath.Join(t.TempDir(), "openclaw-home")
t.Setenv("HOME", osHome)
t.Setenv(openClawStateDirEnv, "")
t.Setenv(openClawHomeEnv, openClawHome)
t.Setenv(openClawConfigEnv, "~/profiles/team/openclaw.json")
want := filepath.Join(openClawHome, "profiles", "team")
if got, err := openClawStateDir(); err != nil || got != want {
t.Fatalf("state dir = %q, %v; want %q", got, err, want)
}
explicitState := filepath.Join(t.TempDir(), "state-wins")
t.Setenv(openClawStateDirEnv, explicitState)
if got, err := openClawStateDir(); err != nil || got != explicitState {
t.Fatalf("explicit state dir = %q, %v; want %q", got, err, explicitState)
}
}
func TestOpenClawStateDirRejectsRelativeOrUncleanPath(t *testing.T) {
unclean := filepath.Join(t.TempDir(), "child") + string(filepath.Separator) + ".." + string(filepath.Separator) + "state"
for _, path := range []string{"relative/state", unclean} {
t.Run(strings.ReplaceAll(path, string(filepath.Separator), "_"), func(t *testing.T) {
t.Setenv(openClawStateDirEnv, path)
if _, err := openClawStateDir(); err == nil {
t.Fatalf("path %q was accepted", path)
}
})
}
}
func TestResolveFromStateDir_ValidatesPackageMetadata(t *testing.T) {
tests := []struct {
name string
mutate func(*packageManifest)
want string
}{
{"name", func(m *packageManifest) { m.Name = "@evil/signer" }, "name"},
{"version", func(m *packageManifest) { m.Version = "latest" }, "version"},
{"os", func(m *packageManifest) { m.OS = []string{"other"} }, "os metadata"},
{"cpu", func(m *packageManifest) { m.CPU = []string{"other"} }, "cpu metadata"},
{"multiple os", func(m *packageManifest) { m.OS = append(m.OS, "other") }, "os metadata"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
manifest := validManifest(fx.spec)
test.mutate(&manifest)
writeJSON(t, fx.packageJSON, manifest, 0600)
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v", err)
}
})
}
}
func TestResolveFromStateDir_RejectsParentAndBinarySymlinks(t *testing.T) {
t.Run("parent", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
realBin := fx.binDir + "-real"
if err := os.Rename(fx.binDir, realBin); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realBin, fx.binDir); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatalf("error = %v", err)
}
})
t.Run("binary", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
realBinary := fx.binary + "-real"
if err := os.Rename(fx.binary, realBinary); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realBinary, fx.binary); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatalf("error = %v", err)
}
})
}
func TestResolveFromStateDir_RejectsInsecureModeAndNonExecutable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX mode test")
}
t.Run("writable parent", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
if err := os.Chmod(fx.packageDir, 0777); err != nil {
t.Fatal(err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "writable") {
t.Fatalf("error = %v", err)
}
})
t.Run("non executable", func(t *testing.T) {
fx := newOptionalPackageFixture(t, runtime.GOOS, runtime.GOARCH)
if err := os.Chmod(fx.binary, 0600); err != nil {
t.Fatal(err)
}
if _, err := resolveFromStateDir(fx.stateDir, runtime.GOOS, runtime.GOARCH); err == nil || !strings.Contains(err.Error(), "executable") {
t.Fatalf("error = %v", err)
}
})
}
func TestResolveFromStateDir_MissingPackageFailsClosed(t *testing.T) {
stateDir := t.TempDir()
if _, err := resolveFromStateDir(stateDir, runtime.GOOS, runtime.GOARCH); err == nil {
t.Fatal("missing optional package was accepted")
}
}
func newOptionalPackageFixture(t *testing.T, goos, goarch string) optionalPackageFixture {
t.Helper()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
t.Skipf("host platform has no optional package: %v", err)
}
stateDir := filepath.Join(t.TempDir(), "openclaw state")
pluginDir := filepath.Join(stateDir, "extensions", pluginID)
packageDir := signerPackageUnder(filepath.Join(pluginDir, "node_modules"), spec)
return writeOptionalPackageFixture(t, optionalPackageFixture{
stateDir: stateDir, pluginDir: pluginDir, packageDir: packageDir, spec: spec,
})
}
func newManagedPackageFixture(t *testing.T, goos, goarch string, hoisted bool) optionalPackageFixture {
t.Helper()
spec, err := signerPackageFor(goos, goarch)
if err != nil {
t.Skipf("host platform has no optional package: %v", err)
}
stateDir := filepath.Join(t.TempDir(), "custom OpenClaw state")
projectDir := filepath.Join(stateDir, "npm", "projects", "larksuite-openclaw-lark-test-generation")
nodeModules := filepath.Join(projectDir, "node_modules")
pluginDir := filepath.Join(nodeModules, signerPackageScope, strings.TrimPrefix(pluginPackageName, signerPackageScope+"/"))
packageNodeModules := filepath.Join(pluginDir, "node_modules")
if hoisted {
packageNodeModules = nodeModules
}
packageDir := signerPackageUnder(packageNodeModules, spec)
return writeOptionalPackageFixture(t, optionalPackageFixture{
stateDir: stateDir, projectDir: projectDir, pluginDir: pluginDir, packageDir: packageDir, spec: spec,
})
}
func writeOptionalPackageFixture(t *testing.T, fx optionalPackageFixture) optionalPackageFixture {
t.Helper()
if err := os.MkdirAll(fx.pluginDir, 0700); err != nil {
t.Fatal(err)
}
packageDir := fx.packageDir
binDir := filepath.Join(packageDir, "bin")
if err := os.MkdirAll(binDir, 0700); err != nil {
t.Fatal(err)
}
packageJSON := filepath.Join(packageDir, "package.json")
writeJSON(t, packageJSON, validManifest(fx.spec), 0600)
binary := filepath.Join(binDir, fx.spec.binaryName)
binaryBytes := []byte("test optional-package signer")
if err := os.WriteFile(binary, binaryBytes, 0700); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(binaryBytes)
fx.packageJSON, fx.binDir, fx.binary = packageJSON, binDir, binary
fx.binaryDigest = hex.EncodeToString(digest[:])
return fx
}
func inspectPluginDocument(fx optionalPackageFixture, inspectedID, resolvedPath string) map[string]any {
return map[string]any{"plugin": map[string]any{
"id": inspectedID, "name": pluginPackageName, "packageName": pluginPackageName,
"rootDir": fx.pluginDir, "status": "loaded",
"dependencyStatus": map[string]any{"optionalDependencies": []map[string]any{{
"name": fx.spec.name, "installed": true, "optional": true, "resolvedPath": resolvedPath,
}}},
}}
}
func resolveFromInspectDocument(t *testing.T, fx optionalPackageFixture, document any) (resolvedProvider, error) {
t.Helper()
data, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
stubOpenClawInspect(t, data, nil)
return resolveFromInspect(context.Background(), fx.stateDir, runtime.GOOS, runtime.GOARCH)
}
func stubOpenClawInspect(t *testing.T, data []byte, err error) {
t.Helper()
previous := runOpenClawInspect
runOpenClawInspect = func(context.Context, string) ([]byte, error) { return data, err }
t.Cleanup(func() { runOpenClawInspect = previous })
}
func assertResolvedFixture(t *testing.T, got resolvedProvider, fx optionalPackageFixture) {
t.Helper()
if got.binaryPath != fx.binary || got.packageDir != fx.packageDir || got.digest != fx.binaryDigest {
t.Fatalf("resolved = %#v, fixture = %#v", got, fx)
}
}
func validManifest(spec platformPackage) packageManifest {
return packageManifest{Name: spec.name, Version: "1.2.3", OS: []string{spec.npmOS}, CPU: []string{spec.npmCPU}}
}
func writeJSON(t *testing.T, path string, value any, mode os.FileMode) {
t.Helper()
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatal(err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, mode); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin || linux
package keylessprovider
import (
"fmt"
"os"
"syscall"
"github.com/larksuite/cli/internal/vfs"
)
func validateProviderObject(path string, wantDir bool) error {
return validateOwnedObject(path, wantDir)
}
func validateInspectExecutable(path string) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect OpenClaw executable: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("OpenClaw executable must be a regular non-symlink file")
}
if info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("OpenClaw executable is group/world writable")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || int(stat.Uid) != os.Getuid() && stat.Uid != 0 {
return fmt.Errorf("OpenClaw executable is not owned by the current user or root")
}
if stat.Nlink != 1 {
return fmt.Errorf("OpenClaw executable must have exactly one hard link")
}
return nil
}
func validateOwnedObject(path string, wantDir bool) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect provider object %s: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("provider object must not be a symlink: %s", path)
}
if wantDir != info.IsDir() || (!wantDir && !info.Mode().IsRegular()) {
return fmt.Errorf("provider object has unexpected type: %s", path)
}
if info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("provider object is group/world writable: %s (mode %o)", path, info.Mode().Perm())
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || int(stat.Uid) != os.Getuid() {
return fmt.Errorf("provider object is not owned by the current user: %s", path)
}
if !wantDir && stat.Nlink != 1 {
return fmt.Errorf("provider file must have exactly one hard link: %s", path)
}
return nil
}

View File

@@ -1,149 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package keylessprovider
import (
"fmt"
"os"
"unsafe"
"github.com/larksuite/cli/internal/vfs"
"golang.org/x/sys/windows"
)
func validateProviderObject(path string, wantDir bool) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect provider object %s: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("provider object must not be a symlink: %s", path)
}
if wantDir != info.IsDir() || (!wantDir && !info.Mode().IsRegular()) {
return fmt.Errorf("provider object has unexpected type: %s", path)
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode provider object path: %w", err)
}
attrs, err := windows.GetFileAttributes(path16)
if err != nil {
return fmt.Errorf("inspect provider object attributes: %w", err)
}
if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("provider object must not be a reparse point: %s", path)
}
sd, err := windows.GetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
return fmt.Errorf("inspect provider object security descriptor: %w", err)
}
owner, _, err := sd.Owner()
if err != nil || owner == nil {
return fmt.Errorf("inspect provider object owner: %w", err)
}
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return fmt.Errorf("inspect current Windows user: %w", err)
}
if !owner.Equals(user.User.Sid) {
return fmt.Errorf("provider object is not owned by the current user: %s", path)
}
if err := validateWindowsDACL(sd, user.User.Sid); err != nil {
return fmt.Errorf("provider object has unsafe permissions: %s: %w", path, err)
}
return nil
}
func validateInspectExecutable(path string) error {
info, err := vfs.Lstat(path)
if err != nil {
return fmt.Errorf("inspect OpenClaw executable: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("OpenClaw executable must be a regular non-symlink file")
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
attrs, err := windows.GetFileAttributes(path16)
if err != nil || attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return fmt.Errorf("OpenClaw executable must not be a reparse point")
}
sd, err := windows.GetNamedSecurityInfo(
path,
windows.SE_FILE_OBJECT,
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
)
if err != nil {
return err
}
owner, _, err := sd.Owner()
if err != nil || owner == nil {
return fmt.Errorf("inspect OpenClaw executable owner: %w", err)
}
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return err
}
if !owner.Equals(user.User.Sid) &&
!owner.IsWellKnown(windows.WinLocalSystemSid) &&
!owner.IsWellKnown(windows.WinBuiltinAdministratorsSid) {
return fmt.Errorf("OpenClaw executable has an untrusted owner")
}
if err := validateWindowsDACL(sd, user.User.Sid); err != nil {
return fmt.Errorf("OpenClaw executable has unsafe permissions: %w", err)
}
return nil
}
func validateWindowsDACL(sd *windows.SECURITY_DESCRIPTOR, currentUser *windows.SID) error {
dacl, _, err := sd.DACL()
if err != nil || dacl == nil {
return fmt.Errorf("missing or unreadable DACL")
}
const (
fileDeleteChild windows.ACCESS_MASK = 0x00000040
writeMask = windows.GENERIC_ALL | windows.GENERIC_WRITE |
windows.WRITE_DAC | windows.WRITE_OWNER | windows.DELETE |
windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
windows.FILE_WRITE_EA | windows.FILE_WRITE_ATTRIBUTES | fileDeleteChild
)
for i := uint16(0); i < dacl.AceCount; i++ {
var ace *windows.ACCESS_ALLOWED_ACE
if err := windows.GetAce(dacl, uint32(i), &ace); err != nil {
return fmt.Errorf("read ACL entry %d: %w", i, err)
}
if ace == nil || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE {
continue
}
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
return fmt.Errorf("unsupported allow ACL entry type %d", ace.Header.AceType)
}
if ace.Mask&writeMask == 0 {
continue
}
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
if sid == nil || !sid.IsValid() {
return fmt.Errorf("ACL entry %d has invalid SID", i)
}
if sid.Equals(currentUser) ||
sid.IsWellKnown(windows.WinLocalSystemSid) ||
sid.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
sid.IsWellKnown(windows.WinCreatorOwnerSid) ||
sid.IsWellKnown(windows.WinCreatorOwnerRightsSid) {
continue
}
return fmt.Errorf("write access is granted to SID %s", sid.String())
}
return nil
}

View File

@@ -1,210 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keysigner defines the pluggable signing abstraction used by the
// private_key_jwt registration and authentication flow.
//
// Platform implementations hold non-exportable private keys in TPM 2.0 on
// supported Linux/Windows targets and in Keychain on macOS. Build constraints
// select the implementation for each target, and each backend registers itself
// through Register from init().
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"errors"
"fmt"
"math/big"
"strings"
)
// KeyRef identifies a non-exportable signing key held by a backend
// (TEE/TPM/Keychain). It is a stable handle (label), never the key material.
type KeyRef struct {
// Label is the backend key label/tag (e.g. "larksuite-cli-agent").
Label string
}
// Signer signs JWS signing inputs with a non-exportable key.
type Signer interface {
// EnsureKey returns the public key for ref, creating the key if absent.
EnsureKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
// PublicKey returns the public key for ref without creating it.
PublicKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
// Sign signs signingInput and returns a JOSE-format signature plus the JWS
// alg ("ES256"/"RS256"). Implementations apply the alg's hash and, for
// ECDSA, MUST return the fixed-width r||s form required by RFC 7518 §3.4
// (not ASN.1 DER), because the backend (TPM/Keychain) typically yields DER.
Sign(ctx context.Context, ref KeyRef, signingInput []byte) (sig []byte, alg string, err error)
}
// Supported JWS algorithms.
const (
AlgES256 = "ES256"
AlgRS256 = "RS256"
)
// DefaultKeyLabel is the backend key label lark-cli uses for its device signing
// key. One non-exportable key is created on first private_key_jwt registration
// and reused across subsequent app registrations on the same device.
const DefaultKeyLabel = "larksuite-cli-agent"
// HardwareInfo describes the secure hardware backing a Signer, as reported by a
// HardwareProber. It is advisory/diagnostic: it tells a user whether
// private_key_jwt can use a real TEE on this device.
type HardwareInfo struct {
Backend string // backing technology, e.g. "tpm2" or "keychain"
Available bool // the hardware is present and usable for signing
VendorName string // hardware vendor/manufacturer, when known
VendorInfo string // additional vendor detail, when known
Reason string // when Available is false, a human-readable cause
}
// HardwareProber is an optional capability a Signer may implement to report on
// the secure hardware backing it (TPM/TEE vendor and availability) WITHOUT
// creating or using a key. Probing never mutates key state.
type HardwareProber interface {
ProbeHardware(ctx context.Context) (HardwareInfo, error)
}
// ProbeActiveHardware probes the active signer's secure hardware. ok is false
// when there is no active signer or it does not implement HardwareProber — in
// which case private_key_jwt is unsupported on this build. When ok is true, info
// reports availability and, if unavailable, info.Reason explains why.
func ProbeActiveHardware(ctx context.Context) (info HardwareInfo, ok bool, err error) {
return probeHardware(ctx, Active())
}
// probeHardware is the registry-independent core of ProbeActiveHardware, so it
// can be unit-tested without touching the global signer.
func probeHardware(ctx context.Context, s Signer) (HardwareInfo, bool, error) {
p, ok := s.(HardwareProber)
if !ok {
return HardwareInfo{}, false, nil
}
info, err := p.ProbeHardware(ctx)
return info, true, err
}
// cleanProbeError renders err's message with redundant re-wraps collapsed. Some
// backends (e.g. facebookincubator/sks) wrap an error twice with the SAME "%w"
// prefix, yielding "P: P: cause"; this peels each outer layer whose only
// contribution is to repeat the prefix already present in the wrapped error,
// leaving a single "P: cause". A layer that adds genuinely new context is kept.
func cleanProbeError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
for {
inner := errors.Unwrap(err)
if inner == nil {
break
}
innerMsg := inner.Error()
prefix, ok := strings.CutSuffix(msg, innerMsg)
if !ok || prefix == "" || !strings.HasPrefix(innerMsg, prefix) {
break
}
msg, err = innerMsg, inner
}
return msg
}
// AlgForKey returns the JWS alg for a public key: EC P-256 -> ES256, RSA -> RS256.
// The signer backend chooses the key type (the macOS keychain signer uses an
// RSA-2048 key, hence RS256).
func AlgForKey(pub crypto.PublicKey) (string, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
if k.Curve == elliptic.P256() {
return AlgES256, nil
}
return "", fmt.Errorf("keysigner: unsupported EC curve %q (only P-256/ES256)", k.Curve.Params().Name)
case *rsa.PublicKey:
return AlgRS256, nil
default:
return "", fmt.Errorf("keysigner: unsupported public key type %T", pub)
}
}
// ecdsaDERToJOSE converts an ASN.1 DER-encoded ECDSA signature — the form most
// TEE/TPM backends emit (e.g. facebookincubator/sks marshals the TPM's r,s with
// asn1.Marshal) — into the fixed-width r||s form JWS requires for ES256
// (RFC 7518 §3.4). byteLen is the curve coordinate size (32 for P-256), so the
// result is exactly 2*byteLen bytes with r and s each left-zero-padded.
//
// This is intentionally part of the pure-stdlib core (not a platform signer) so
// it can be unit-tested with a software key on any machine, including TPM-less CI.
func ecdsaDERToJOSE(der []byte, byteLen int) ([]byte, error) {
var sig struct{ R, S *big.Int }
rest, err := asn1.Unmarshal(der, &sig)
if err != nil {
return nil, fmt.Errorf("keysigner: parse ECDSA DER signature: %w", err)
}
if len(rest) != 0 {
return nil, fmt.Errorf("keysigner: %d trailing byte(s) after ECDSA DER signature", len(rest))
}
if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 {
return nil, fmt.Errorf("keysigner: ECDSA signature has non-positive r/s")
}
// Guard before FillBytes, which panics if the scalar does not fit in byteLen.
if sig.R.BitLen() > byteLen*8 || sig.S.BitLen() > byteLen*8 {
return nil, fmt.Errorf("keysigner: ECDSA r/s exceeds %d-byte coordinate", byteLen)
}
out := make([]byte, 2*byteLen)
sig.R.FillBytes(out[:byteLen])
sig.S.FillBytes(out[byteLen:])
return out, nil
}
// EncodePublicKey marshals pub to PKIX DER and base64-encodes it (std encoding),
// matching the public-key form the registration backend binds to the app.
func EncodePublicKey(pub crypto.PublicKey) (string, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", fmt.Errorf("keysigner: encode public key: %w", err)
}
return base64.StdEncoding.EncodeToString(der), nil
}
// PublicKeyJWK returns the RFC 7517 JSON Web Key for pub, used to embed the
// public key in the attestation JWT's "jwk" header so the registration backend
// can bind it to the app. EC keys use base64url fixed-width coordinates
// (RFC 7518 §6.2.1); RSA keys use base64url-encoded modulus and exponent.
func PublicKeyJWK(pub crypto.PublicKey) (map[string]any, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
if k.Curve != elliptic.P256() {
return nil, fmt.Errorf("keysigner: JWK supports EC P-256 only, got %q", k.Curve.Params().Name)
}
const coordLen = 32 // P-256 field element size
x := make([]byte, coordLen)
y := make([]byte, coordLen)
k.X.FillBytes(x)
k.Y.FillBytes(y)
return map[string]any{
"use": "sig",
"kty": "EC",
"crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(x),
"y": base64.RawURLEncoding.EncodeToString(y),
}, nil
case *rsa.PublicKey:
return map[string]any{
"use": "sig",
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
}, nil
default:
return nil, fmt.Errorf("keysigner: unsupported public key type %T for JWK", pub)
}
}

View File

@@ -1,240 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"math/big"
"reflect"
"testing"
)
func TestAlgForKey(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if alg, err := AlgForKey(ec.Public()); err != nil || alg != AlgES256 {
t.Errorf("P-256: alg=%q err=%v, want ES256/nil", alg, err)
}
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
if alg, err := AlgForKey(rsaKey.Public()); err != nil || alg != AlgRS256 {
t.Errorf("RSA: alg=%q err=%v, want RS256/nil", alg, err)
}
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if _, err := AlgForKey(ec384.Public()); err == nil {
t.Error("P-384: expected unsupported-curve error")
}
if _, err := AlgForKey("not a key"); err == nil {
t.Error("string: expected unsupported-type error")
}
}
func TestEncodePublicKeyRoundTrip(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
enc, err := EncodePublicKey(ec.Public())
if err != nil {
t.Fatal(err)
}
der, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
t.Fatalf("not valid base64: %v", err)
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
t.Fatalf("not valid PKIX: %v", err)
}
if !reflect.DeepEqual(pub, ec.Public()) {
t.Error("public key did not round-trip")
}
}
func TestPublicKeyJWK_EC(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
jwk, err := PublicKeyJWK(ec.Public())
if err != nil {
t.Fatal(err)
}
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" {
t.Errorf("jwk = %v, want kty=EC crv=P-256", jwk)
}
if jwk["use"] != "sig" {
t.Errorf("jwk use = %v, want sig", jwk["use"])
}
x, _ := jwk["x"].(string)
xb, err := base64.RawURLEncoding.DecodeString(x)
if err != nil || len(xb) != 32 {
t.Errorf("x = %q (decoded %d bytes), want 32-byte base64url", x, len(xb))
}
if _, ok := jwk["y"].(string); !ok {
t.Error("jwk missing y")
}
}
func TestPublicKeyJWK_RSA(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
jwk, err := PublicKeyJWK(rsaKey.Public())
if err != nil {
t.Fatal(err)
}
if jwk["kty"] != "RSA" || jwk["n"] == "" || jwk["e"] == "" {
t.Errorf("jwk = %v, want kty=RSA with n,e", jwk)
}
if jwk["use"] != "sig" {
t.Errorf("jwk use = %v, want sig", jwk["use"])
}
}
func TestPublicKeyJWK_UnsupportedCurve(t *testing.T) {
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if _, err := PublicKeyJWK(ec384.Public()); err == nil {
t.Error("P-384: expected error")
}
}
func TestECDSADERToJOSE(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
// Iterate so we hit signatures whose r or s has its high bit set (ASN.1 pads
// those with a leading 0x00) and whose scalars are short (need left-zero
// padding) — verifying fixed-width conversion in both directions.
for i := 0; i < 64; i++ {
digest := sha256.Sum256([]byte{byte(i), byte(i >> 8), 'j', 'w', 't'})
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatal(err)
}
jose, err := ecdsaDERToJOSE(der, 32)
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
if len(jose) != 64 {
t.Fatalf("iter %d: len(jose)=%d, want 64 (fixed-width r||s)", i, len(jose))
}
r := new(big.Int).SetBytes(jose[:32])
s := new(big.Int).SetBytes(jose[32:])
if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) {
t.Fatalf("iter %d: converted r||s did not verify against the public key", i)
}
}
}
func TestECDSADERToJOSE_Errors(t *testing.T) {
if _, err := ecdsaDERToJOSE([]byte{0x01, 0x02, 0x03}, 32); err == nil {
t.Error("garbage DER: expected error")
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("trailing"))
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatal(err)
}
if _, err := ecdsaDERToJOSE(append(der, 0x00), 32); err == nil {
t.Error("DER with trailing byte: expected error")
}
}
type stubSigner struct{}
func (stubSigner) EnsureKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
func (stubSigner) PublicKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
func (stubSigner) Sign(context.Context, KeyRef, []byte) ([]byte, string, error) { return nil, "", nil }
func TestCleanProbeError(t *testing.T) {
cause := errors.New("open /dev/tpmrm0: permission denied")
const p = "sks: error fetching Secure Hardware Vendor Data: "
// sks double-wraps with the same %w prefix → collapse to a single prefix.
doubled := fmt.Errorf(p+"%w", fmt.Errorf(p+"%w", cause))
if got, want := cleanProbeError(doubled), p+cause.Error(); got != want {
t.Errorf("doubled: got %q, want %q", got, want)
}
// Triple wrap collapses too.
if got, want := cleanProbeError(fmt.Errorf(p+"%w", doubled)), p+cause.Error(); got != want {
t.Errorf("tripled: got %q, want %q", got, want)
}
// A layer adding genuinely new context is preserved.
if got, want := cleanProbeError(fmt.Errorf("load: %w", cause)), "load: "+cause.Error(); got != want {
t.Errorf("distinct prefix: got %q, want %q", got, want)
}
// nil and unwrapped-leaf cases.
if got := cleanProbeError(nil); got != "" {
t.Errorf("nil: got %q, want empty", got)
}
if got := cleanProbeError(cause); got != cause.Error() {
t.Errorf("leaf: got %q, want %q", got, cause.Error())
}
}
type proberSigner struct {
stubSigner
info HardwareInfo
}
func (p proberSigner) ProbeHardware(context.Context) (HardwareInfo, error) { return p.info, nil }
func TestProbeHardware(t *testing.T) {
// nil signer and a signer that does not implement HardwareProber both yield ok=false.
if _, ok, _ := probeHardware(context.Background(), nil); ok {
t.Error("nil signer: ok should be false")
}
if _, ok, _ := probeHardware(context.Background(), stubSigner{}); ok {
t.Error("non-prober signer: ok should be false")
}
want := HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
info, ok, err := probeHardware(context.Background(), proberSigner{info: want})
if err != nil || !ok {
t.Fatalf("prober: ok=%v err=%v, want true/nil", ok, err)
}
if info != want {
t.Errorf("info = %+v, want %+v", info, want)
}
}
func TestRegistry(t *testing.T) {
if Active() != nil {
t.Skip("a signer is already registered in this build")
}
Register(stubSigner{})
if _, ok := Active().(stubSigner); !ok {
t.Error("Active did not return the registered signer")
}
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import "sync"
var (
mu sync.RWMutex
active Signer
)
// Register sets the active Signer. It is typically called from the init() of a
// build-tagged or extension package that provides the platform TEE/Keychain
// implementation. The last registration wins (one backend per platform).
func Register(s Signer) {
mu.Lock()
defer mu.Unlock()
active = s
}
// Active returns the registered Signer, or nil if none is available — in which
// case private_key_jwt is unsupported on this build and only client_secret auth
// can be used.
func Active() Signer {
mu.RLock()
defer mu.RUnlock()
return active
}

View File

@@ -1,760 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// macOS non-exportable Keychain signer (compiled into every darwin build).
//
// It does NOT use the Secure Enclave / hardware TEE (which would require
// code-signing entitlements that are unfriendly to open source). Instead it
// generates an RSA-2048 key directly inside a dedicated app keychain. The
// private key is permanent, sensitive, and non-extractable; it is never present
// in Go memory or a temporary file. Its access list trusts only the creating
// application by default. Signing is RSASSA-PKCS1v15-SHA256 (RS256).
//
// Security and CoreFoundation are called through runtime FFI
// (github.com/ebitengine/purego). Key generation and signing stay inside the OS
// APIs while the binary remains CGO-free and can be cross-compiled for darwin.
//
// Build with: go build (cgo-free; compiled into every darwin build, no tag)
package keysigner
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"unsafe"
"github.com/ebitengine/purego"
"github.com/larksuite/cli/internal/vfs"
)
// ---- Security / CoreFoundation runtime bindings (purego, no cgo) ----
const (
cfFrameworkPath = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
secFrameworkPath = "/System/Library/Frameworks/Security.framework/Security"
// kCFStringEncodingUTF8 (CFStringBuiltInEncodings).
cfStringEncodingUTF8 = 0x08000100
// OSStatus values.
errSecSuccess = 0
// Legacy Security.framework key-generation values from cssmtype.h. The
// legacy API is used because it can target a dedicated file keychain and set
// both a non-extractable attribute and an application-only SecAccess ACL.
cssmAlgIDRSA = 42
cssmKeyUseSign = 0x00000004
cssmKeyUseVerify = 0x00000008
cssmKeyAttrPermanent = 0x00000001
cssmKeyAttrSensitive = 0x00000008
cssmKeyAttrExtractable = 0x00000020
publicKeyAttributes = cssmKeyAttrPermanent | cssmKeyAttrExtractable
privateKeyAttributes = cssmKeyAttrPermanent | cssmKeyAttrSensitive
)
var (
ffiOnce sync.Once
ffiErr error
cfDataCreate func(alloc uintptr, bytes *byte, length int) uintptr
cfDataGetLength func(d uintptr) int
cfDataGetBytePtr func(d uintptr) unsafe.Pointer
cfStringCreate func(alloc uintptr, cstr *byte, encoding uint32) uintptr
cfArrayCreate func(alloc uintptr, values *uintptr, numValues int, cb uintptr) uintptr
cfDictCreateMutable func(alloc uintptr, capacity int, keyCB, valCB uintptr) uintptr
cfDictSetValue func(dict, key, val uintptr)
cfRelease func(ref uintptr)
cfErrorGetCode func(e uintptr) int
dlsymDataPointer func(handle uintptr, name string) *uintptr
secKeychainCreate func(path *byte, passwordLength uint32, password unsafe.Pointer, promptUser uint8, initialAccess uintptr, out *uintptr) int32
secKeychainOpen func(path *byte, out *uintptr) int32
secKeychainUnlock func(keychain uintptr, passwordLength uint32, password unsafe.Pointer, usePassword uint8) int32
secAccessCreate func(descriptor, trustedList uintptr, out *uintptr) int32
secKeyCreatePair func(keychain uintptr, algorithm, keySize uint32, contextHandle uint64, publicKeyUsage, publicKeyAttr, privateKeyUsage, privateKeyAttr uint32, initialAccess uintptr, publicKey, privateKey *uintptr) int32
secKeyCopyExternal func(key uintptr, errOut *uintptr) uintptr
secKeychainItemDelete func(item uintptr) int32
secItemCopyMatching func(query uintptr, result *uintptr) int32
secItemUpdate func(query, attrs uintptr) int32
secKeyCreateSignature func(key, algo, data uintptr, errOut *uintptr) uintptr
// CFTypeRef data-symbol constants (deref to obtain the held ref value).
kSecClass uintptr
kSecClassKey uintptr
kSecAttrKeyClass uintptr
kSecAttrKeyClassPrivate uintptr
kSecAttrKeyType uintptr
kSecAttrKeyTypeRSA uintptr
kSecAttrApplicationLabel uintptr
kSecReturnRef uintptr
kSecMatchSearchList uintptr
kSecAttrLabel uintptr
kCFBooleanTrue uintptr
algRSAPKCS1SHA256 uintptr
// Struct-symbol constants (passed BY ADDRESS, not dereferenced).
cbTypeArray uintptr
cbDictKey uintptr
cbDictValue uintptr
)
// loadFFI resolves the framework functions and constants once. Any failure
// (framework missing, symbol absent) is returned to every caller so signing
// fails cleanly rather than crashing.
func loadFFI() error {
ffiOnce.Do(func() {
// RegisterLibFunc panics when a symbol is unavailable. Convert that into
// the same stable availability error as dlopen/dlsym failures so doctor
// and auth commands never crash on a future macOS without a legacy symbol.
defer func() {
if recovered := recover(); recovered != nil {
ffiErr = fmt.Errorf("keysigner: load Security framework bindings: %v", recovered)
}
}()
cf, err := purego.Dlopen(cfFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
ffiErr = fmt.Errorf("keysigner: dlopen CoreFoundation: %w", err)
return
}
sec, err := purego.Dlopen(secFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
ffiErr = fmt.Errorf("keysigner: dlopen Security: %w", err)
return
}
purego.RegisterLibFunc(&cfDataCreate, cf, "CFDataCreate")
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
purego.RegisterLibFunc(&cfStringCreate, cf, "CFStringCreateWithCString")
purego.RegisterLibFunc(&cfArrayCreate, cf, "CFArrayCreate")
purego.RegisterLibFunc(&cfDictCreateMutable, cf, "CFDictionaryCreateMutable")
purego.RegisterLibFunc(&cfDictSetValue, cf, "CFDictionarySetValue")
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
purego.RegisterLibFunc(&cfErrorGetCode, cf, "CFErrorGetCode")
// purego.Dlsym exposes symbol addresses as uintptr. Bind dlsym with a
// pointer return for data symbols so reading exported CFTypeRef variables
// never performs a uintptr-to-pointer conversion in Go.
purego.RegisterLibFunc(&dlsymDataPointer, purego.RTLD_DEFAULT, "dlsym")
purego.RegisterLibFunc(&secKeychainCreate, sec, "SecKeychainCreate")
purego.RegisterLibFunc(&secKeychainOpen, sec, "SecKeychainOpen")
purego.RegisterLibFunc(&secKeychainUnlock, sec, "SecKeychainUnlock")
purego.RegisterLibFunc(&secAccessCreate, sec, "SecAccessCreate")
purego.RegisterLibFunc(&secKeyCreatePair, sec, "SecKeyCreatePair")
purego.RegisterLibFunc(&secKeyCopyExternal, sec, "SecKeyCopyExternalRepresentation")
purego.RegisterLibFunc(&secKeychainItemDelete, sec, "SecKeychainItemDelete")
purego.RegisterLibFunc(&secItemCopyMatching, sec, "SecItemCopyMatching")
purego.RegisterLibFunc(&secItemUpdate, sec, "SecItemUpdate")
purego.RegisterLibFunc(&secKeyCreateSignature, sec, "SecKeyCreateSignature")
// CFStringRef/CFBooleanRef constants: Dlsym gives the address of the
// exported variable; deref once to read the ref it holds.
derefs := []struct {
dst *uintptr
handle uintptr
name string
}{
{&kSecClass, sec, "kSecClass"},
{&kSecClassKey, sec, "kSecClassKey"},
{&kSecAttrKeyClass, sec, "kSecAttrKeyClass"},
{&kSecAttrKeyClassPrivate, sec, "kSecAttrKeyClassPrivate"},
{&kSecAttrKeyType, sec, "kSecAttrKeyType"},
{&kSecAttrKeyTypeRSA, sec, "kSecAttrKeyTypeRSA"},
{&kSecAttrApplicationLabel, sec, "kSecAttrApplicationLabel"},
{&kSecReturnRef, sec, "kSecReturnRef"},
{&kSecMatchSearchList, sec, "kSecMatchSearchList"},
{&kSecAttrLabel, sec, "kSecAttrLabel"},
{&kCFBooleanTrue, cf, "kCFBooleanTrue"},
{&algRSAPKCS1SHA256, sec, "kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256"},
}
for _, d := range derefs {
sym := dlsymDataPointer(d.handle, d.name)
if sym == nil {
ffiErr = fmt.Errorf("keysigner: dlsym %s returned zero address", d.name)
return
}
*d.dst = *sym
if *d.dst == 0 {
ffiErr = fmt.Errorf("keysigner: data symbol %s contains a zero reference", d.name)
return
}
}
// Callback structs are passed by address (no deref).
addrs := []struct {
dst *uintptr
handle uintptr
name string
}{
{&cbTypeArray, cf, "kCFTypeArrayCallBacks"},
{&cbDictKey, cf, "kCFTypeDictionaryKeyCallBacks"},
{&cbDictValue, cf, "kCFTypeDictionaryValueCallBacks"},
}
for _, a := range addrs {
sym, e := purego.Dlsym(a.handle, a.name)
if e != nil {
ffiErr = fmt.Errorf("keysigner: dlsym %s: %w", a.name, e)
return
}
if sym == 0 {
ffiErr = fmt.Errorf("keysigner: dlsym %s returned zero address", a.name)
return
}
*a.dst = sym
}
})
return ffiErr
}
// cstr returns a pointer to a NUL-terminated copy of s. The backing array stays
// alive while the returned pointer is reachable.
func cstr(s string) *byte {
b := append([]byte(s), 0)
return &b[0]
}
// cfBytes wraps Go bytes in a CFData (CFDataCreate copies the bytes). Caller
// releases the returned CFDataRef.
func cfBytes(b []byte) uintptr {
var p *byte
if len(b) > 0 {
p = &b[0]
}
d := cfDataCreate(0, p, len(b))
runtime.KeepAlive(b)
return d
}
// keychainSearchArray opens the dedicated keychain file and wraps it in a
// CFArray for kSecMatchSearchList. Caller releases the returned array.
//
// NOTE: SecKeychainOpen / the file-based keychain are deprecated by Apple in
// favor of the data-protection keychain. They still function on current macOS;
// migrating off them is tracked separately and is independent of the cgo→purego
// change (the original cgo version used the same APIs).
func keychainSearchArray(keychainPath string) (uintptr, error) {
var kc uintptr
if st := secKeychainOpen(cstr(keychainPath), &kc); st != errSecSuccess {
return 0, keychainError("open keychain", int(st))
}
vals := [1]uintptr{kc}
arr := cfArrayCreate(0, &vals[0], 1, cbTypeArray)
cfRelease(kc) // the array retains it
if arr == 0 {
return 0, fmt.Errorf("keysigner: CFArrayCreate(search list) failed")
}
return arr, nil
}
// findPrivateKey locates the non-extractable private key by its application
// label within the dedicated keychain. Caller releases the returned SecKeyRef.
func findPrivateKey(appLabel []byte, keychainPath string) (uintptr, error) {
search, err := keychainSearchArray(keychainPath)
if err != nil {
return 0, err
}
defer cfRelease(search)
labelData := cfBytes(appLabel)
defer cfRelease(labelData)
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if q == 0 {
return 0, fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
}
defer cfRelease(q)
cfDictSetValue(q, kSecClass, kSecClassKey)
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
cfDictSetValue(q, kSecReturnRef, kCFBooleanTrue)
cfDictSetValue(q, kSecMatchSearchList, search)
var keyRef uintptr
if st := secItemCopyMatching(q, &keyRef); st != errSecSuccess {
return 0, keychainError("find private key", int(st))
}
return keyRef, nil
}
// These seams keep lifecycle tests hermetic. Production calls Security.framework
// directly, so the generated keychain password never appears in process argv.
var (
createKeychainFile = createKeychainFileFFI
unlockKeychainFile = unlockKeychainFileFFI
)
// keychainSigner implements Signer using a macOS non-exportable Keychain key.
type keychainSigner struct{}
func init() { Register(keychainSigner{}) }
// ProbeHardware reports the macOS Keychain backend backing this signer. The
// keychain signer is compiled into every darwin build and needs no special
// hardware, so it reports available whenever its framework bindings load.
// It performs no key access, so it never prompts. Implementing HardwareProber
// is what lets `doctor` report the signer as present rather than treating the
// (prober-less) signer as "no platform signer in this build".
func (keychainSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
info := HardwareInfo{Backend: "keychain", VendorName: "macOS Keychain"}
// A missing framework or symbol is a status (Available=false via Reason),
// not a probe error. Loading symbols does not touch the keychain or prompt.
if err := loadFFI(); err != nil {
info.Reason = err.Error()
return info, nil //nolint:nilerr // absence is reported via Reason, not as an error
}
info.Available = true
return info, nil
}
func (keychainSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
if md, err := readKeyMetadata(ref.Label); err == nil {
return decodePublicKey(md.PublicKey)
} else if !os.IsNotExist(err) {
return nil, err
}
return createKeychainKey(ref.Label)
}
func (keychainSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
md, err := readKeyMetadata(ref.Label)
if err != nil {
return nil, err
}
return decodePublicKey(md.PublicKey)
}
func (keychainSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
if err := loadFFI(); err != nil {
return nil, "", err
}
md, err := readKeyMetadata(ref.Label)
if err != nil {
return nil, "", err
}
appLabel, err := hex.DecodeString(md.AppLabel)
if err != nil {
return nil, "", fmt.Errorf("keysigner: decode app label: %w", err)
}
if len(appLabel) == 0 {
// Guard the &appLabel[0] pointer below against corrupted metadata.
return nil, "", fmt.Errorf("keysigner: key metadata for %q has empty app label", ref.Label)
}
keychain, err := ensureKeychain()
if err != nil {
return nil, "", err
}
keyRef, err := findPrivateKey(appLabel, keychain)
if err != nil {
return nil, "", err
}
defer cfRelease(keyRef)
digest := sha256.Sum256(signingInput)
digestData := cfBytes(digest[:])
defer cfRelease(digestData)
var errRef uintptr
sigRef := secKeyCreateSignature(keyRef, algRSAPKCS1SHA256, digestData, &errRef)
if sigRef == 0 {
code := 0
if errRef != 0 {
code = cfErrorGetCode(errRef)
cfRelease(errRef)
}
return nil, "", fmt.Errorf("keysigner: SecKeyCreateSignature failed (CFError %d)", code)
}
defer cfRelease(sigRef)
n := cfDataGetLength(sigRef)
bp := cfDataGetBytePtr(sigRef)
out := make([]byte, n)
copy(out, unsafe.Slice((*byte)(bp), n))
// RS256: the SecKey PKCS1v15-SHA256 signature is the JOSE signature as-is.
return out, AlgRS256, nil
}
// keyMetadata records the public key + the keychain application-label used to
// locate the non-extractable private key.
type keyMetadata struct {
PublicKey string `json:"public_key"` // PKIX DER, std base64 (see EncodePublicKey)
AppLabel string `json:"app_label"` // hex(sha1(PKCS1 public key))
}
func createKeychainKey(label string) (crypto.PublicKey, error) {
metadataPath, err := keyMetadataPath(label)
if err != nil {
return nil, err
}
if err := loadFFI(); err != nil {
return nil, err
}
keychain, err := ensureKeychain()
if err != nil {
return nil, err
}
var keychainRef uintptr
if st := secKeychainOpen(cstr(keychain), &keychainRef); st != errSecSuccess {
return nil, keychainError("open keychain for key generation", int(st))
}
if keychainRef == 0 {
return nil, fmt.Errorf("keysigner: open keychain for key generation returned an empty reference")
}
defer cfRelease(keychainRef)
descriptor := cfStringCreate(0, cstr(label), cfStringEncodingUTF8)
if descriptor == 0 {
return nil, fmt.Errorf("keysigner: create key access descriptor failed")
}
defer cfRelease(descriptor)
// A nil trusted list means only this application is trusted without a
// confirmation dialog. This is intentionally stricter than security(1) -A.
var access uintptr
if st := secAccessCreate(descriptor, 0, &access); st != errSecSuccess {
return nil, keychainError("create key access policy", int(st))
}
if access == 0 {
return nil, fmt.Errorf("keysigner: create key access policy returned an empty reference")
}
defer cfRelease(access)
var publicKeyRef, privateKeyRef uintptr
status := secKeyCreatePair(
keychainRef,
cssmAlgIDRSA,
2048,
0,
cssmKeyUseVerify,
publicKeyAttributes,
cssmKeyUseSign,
privateKeyAttributes,
access,
&publicKeyRef,
&privateKeyRef,
)
deleteAndRelease := func(keyRef uintptr) {
if keyRef != 0 {
_ = secKeychainItemDelete(keyRef)
cfRelease(keyRef)
}
}
if status != errSecSuccess {
deleteAndRelease(privateKeyRef)
deleteAndRelease(publicKeyRef)
return nil, keychainError("generate non-extractable RSA key", int(status))
}
if publicKeyRef == 0 || privateKeyRef == 0 {
deleteAndRelease(privateKeyRef)
deleteAndRelease(publicKeyRef)
return nil, fmt.Errorf("keysigner: key generation returned an empty key reference")
}
defer cfRelease(publicKeyRef)
defer cfRelease(privateKeyRef)
committed := false
defer func() {
if !committed {
_ = secKeychainItemDelete(privateKeyRef)
_ = secKeychainItemDelete(publicKeyRef)
}
}()
var exportErr uintptr
publicDERRef := secKeyCopyExternal(publicKeyRef, &exportErr)
if publicDERRef == 0 {
code := 0
if exportErr != 0 {
code = cfErrorGetCode(exportErr)
cfRelease(exportErr)
}
return nil, fmt.Errorf("keysigner: export public key failed (CFError %d)", code)
}
defer cfRelease(publicDERRef)
publicDERLength := cfDataGetLength(publicDERRef)
publicDERPointer := cfDataGetBytePtr(publicDERRef)
if publicDERLength <= 0 || publicDERPointer == nil {
return nil, fmt.Errorf("keysigner: exported public key is empty")
}
publicDER := make([]byte, publicDERLength)
copy(publicDER, unsafe.Slice((*byte)(publicDERPointer), publicDERLength))
publicKey, err := x509.ParsePKCS1PublicKey(publicDER)
if err != nil {
return nil, fmt.Errorf("keysigner: parse generated RSA public key: %w", err)
}
appLabel := sha1.Sum(publicDER)
if err := setKeychainKeyLabel(appLabel[:], keychain, label); err != nil {
return nil, err
}
encodedPub, err := EncodePublicKey(publicKey)
if err != nil {
return nil, err
}
if err := writeKeyMetadata(metadataPath, keyMetadata{PublicKey: encodedPub, AppLabel: hex.EncodeToString(appLabel[:])}); err != nil {
return nil, err
}
committed = true
return publicKey, nil
}
func setKeychainKeyLabel(appLabel []byte, keychain, label string) error {
if err := loadFFI(); err != nil {
return err
}
search, err := keychainSearchArray(keychain)
if err != nil {
return err
}
defer cfRelease(search)
labelData := cfBytes(appLabel)
defer cfRelease(labelData)
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if q == 0 {
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
}
defer cfRelease(q)
cfDictSetValue(q, kSecClass, kSecClassKey)
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
cfDictSetValue(q, kSecMatchSearchList, search)
cfLabel := cfStringCreate(0, cstr(label), cfStringEncodingUTF8)
if cfLabel == 0 {
return fmt.Errorf("keysigner: CFStringCreateWithCString failed")
}
defer cfRelease(cfLabel)
attrs := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if attrs == 0 {
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(attrs) failed")
}
defer cfRelease(attrs)
cfDictSetValue(attrs, kSecAttrLabel, cfLabel)
if st := secItemUpdate(q, attrs); st != errSecSuccess {
return keychainError("set keychain key label", int(st))
}
return nil
}
func decodePublicKey(encoded string) (crypto.PublicKey, error) {
der, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("keysigner: decode public key: %w", err)
}
return x509.ParsePKIXPublicKey(der)
}
func readKeyMetadata(label string) (*keyMetadata, error) {
path, err := keyMetadataPath(label)
if err != nil {
return nil, err
}
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err // preserves os.ErrNotExist for EnsureKey
}
var md keyMetadata
if err := json.Unmarshal(data, &md); err != nil {
return nil, fmt.Errorf("keysigner: parse key metadata: %w", err)
}
return &md, nil
}
func writeKeyMetadata(path string, md keyMetadata) error {
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
data, err := json.MarshalIndent(md, "", " ")
if err != nil {
return err
}
return vfs.WriteFile(path, data, 0600)
}
func ensureKeychain() (string, error) {
keychainPath, err := keychainFilePath()
if err != nil {
return "", err
}
password, err := keychainPassword()
if err != nil {
return "", err
}
defer clear(password)
if _, err := vfs.Stat(keychainPath); err != nil {
if !os.IsNotExist(err) {
return "", fmt.Errorf("keysigner: stat keychain: %w", err)
}
if err := vfs.MkdirAll(filepath.Dir(keychainPath), 0700); err != nil {
return "", err
}
if err := createKeychainFile(keychainPath, password); err != nil {
return "", err
}
}
// A file keychain can be locked after logout, reboot, an idle interval, or an
// explicit `security lock-keychain`. SecKeychainOpen may still succeed while
// it is locked; the failure then appears later at signing time (commonly as
// SecKeyCreateSignature CFError -128). Always unlock the dedicated keychain
// before returning it, including when the file already existed.
if err := unlockKeychainFile(keychainPath, password); err != nil {
return "", err
}
return keychainPath, nil
}
func createKeychainFileFFI(path string, password []byte) error {
if err := loadFFI(); err != nil {
return err
}
pathBytes := append([]byte(path), 0)
var keychain uintptr
status := secKeychainCreate(
&pathBytes[0],
uint32(len(password)),
byteSlicePointer(password),
0, // promptUser=false: unattended creation must never display UI.
0, // initialAccess is ignored; Apple documents passing NULL.
&keychain,
)
runtime.KeepAlive(pathBytes)
runtime.KeepAlive(password)
if status != errSecSuccess {
return keychainError("create keychain", int(status))
}
if keychain == 0 {
return fmt.Errorf("keysigner: create keychain returned an empty reference")
}
defer cfRelease(keychain)
// Keep the system's default lock policy. ensureKeychain explicitly unlocks
// this dedicated keychain with its generated password before every use, so
// changing settings here would be unnecessary and could trigger system UI.
return nil
}
func unlockKeychainFileFFI(path string, password []byte) error {
if err := loadFFI(); err != nil {
return err
}
pathBytes := append([]byte(path), 0)
var keychain uintptr
status := secKeychainOpen(&pathBytes[0], &keychain)
runtime.KeepAlive(pathBytes)
if status != errSecSuccess {
return keychainError("open keychain for unlock", int(status))
}
if keychain == 0 {
return fmt.Errorf("keysigner: open keychain for unlock returned an empty reference")
}
defer cfRelease(keychain)
status = secKeychainUnlock(keychain, uint32(len(password)), byteSlicePointer(password), 1)
runtime.KeepAlive(password)
if status != errSecSuccess {
return keychainError("unlock keychain", int(status))
}
return nil
}
func byteSlicePointer(data []byte) unsafe.Pointer {
if len(data) == 0 {
return nil
}
return unsafe.Pointer(&data[0])
}
func keysignerDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("keysigner: resolve config dir: %w", err)
}
return filepath.Join(configDir, "lark-cli", "keysigner"), nil
}
func keychainFilePath() (string, error) {
dir, err := keysignerDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "lark-cli.keychain"), nil
}
func keychainPassword() ([]byte, error) {
dir, err := keysignerDir()
if err != nil {
return nil, err
}
path := filepath.Join(dir, "keychain.pass")
if data, err := vfs.ReadFile(path); err == nil {
defer clear(data)
if pw := bytes.TrimSpace(data); len(pw) != 0 {
return append([]byte(nil), pw...), nil
}
return nil, fmt.Errorf("keysigner: empty keychain password")
} else if !os.IsNotExist(err) {
return nil, err
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return nil, err
}
defer clear(buf)
pw := make([]byte, hex.EncodedLen(len(buf)))
hex.Encode(pw, buf)
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
clear(pw)
return nil, err
}
stored := append(append([]byte(nil), pw...), '\n')
if err := vfs.WriteFile(path, stored, 0600); err != nil {
clear(stored)
clear(pw)
return nil, err
}
clear(stored)
return pw, nil
}
func keyMetadataPath(label string) (string, error) {
dir, err := keysignerDir()
if err != nil {
return "", err
}
id := sha256.Sum256([]byte(label))
return filepath.Join(dir, "keys", hex.EncodeToString(id[:])+".json"), nil
}
func keychainError(operation string, status int) error {
switch status {
case -25299:
return fmt.Errorf("keysigner: %s: key already exists", operation)
case -25300:
return fmt.Errorf("keysigner: %s: key not found", operation)
case -2:
return fmt.Errorf("keysigner: %s: allocation failed", operation)
default:
return fmt.Errorf("keysigner: %s: Security framework status %d", operation, status)
}
}

View File

@@ -1,282 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestKeychainSignerRegistered confirms every Darwin build self-registers the
// signer (init → Register), so keysigner.Active() is non-nil. No keychain access.
func TestKeychainSignerRegistered(t *testing.T) {
if _, ok := Active().(keychainSigner); !ok {
t.Fatalf("Active() = %T, want keychainSigner (Darwin build must self-register)", Active())
}
}
func TestKeychainFFIBindings(t *testing.T) {
if err := loadFFI(); err != nil {
t.Fatalf("loadFFI: %v", err)
}
if secKeychainCreate == nil || secKeychainOpen == nil || secKeychainUnlock == nil ||
secAccessCreate == nil || secKeyCreatePair == nil || secKeyCopyExternal == nil || secKeychainItemDelete == nil {
t.Fatal("one or more keychain functions were not registered")
}
}
func TestPrivateKeyGenerationAttributesAreNonExtractable(t *testing.T) {
if privateKeyAttributes&cssmKeyAttrPermanent == 0 {
t.Fatal("private key must be stored permanently in the dedicated keychain")
}
if privateKeyAttributes&cssmKeyAttrSensitive == 0 {
t.Fatal("private key must be marked sensitive")
}
if privateKeyAttributes&cssmKeyAttrExtractable != 0 {
t.Fatal("private key must not be extractable")
}
if publicKeyAttributes&cssmKeyAttrExtractable == 0 {
t.Fatal("public key must remain exportable")
}
}
// TestEnsureKeychainUnlocksExistingKeychain covers the recovery path for a
// dedicated file keychain that was created in an earlier process and has since
// become locked. No real Security.framework call is executed.
func TestEnsureKeychainUnlocksExistingKeychain(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, "Library", "Application Support", "lark-cli", "keysigner")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
keychainPath := filepath.Join(dir, "lark-cli.keychain")
if err := os.WriteFile(keychainPath, []byte("not-a-real-keychain"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "keychain.pass"), []byte("test-password\n"), 0600); err != nil {
t.Fatal(err)
}
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(string, []byte) error {
t.Fatal("createKeychainFile called for an existing keychain")
return nil
}
type unlockCall struct {
path string
password []byte
}
var calls []unlockCall
var borrowedPassword []byte
unlockKeychainFile = func(path string, password []byte) error {
borrowedPassword = password
calls = append(calls, unlockCall{path: path, password: append([]byte(nil), password...)})
return nil
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
got, err := ensureKeychain()
if err != nil {
t.Fatalf("ensureKeychain: %v", err)
}
if got != keychainPath {
t.Fatalf("ensureKeychain path = %q, want %q", got, keychainPath)
}
want := []unlockCall{{path: keychainPath, password: []byte("test-password")}}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("unlock calls = %#v, want %#v", calls, want)
}
for i, value := range borrowedPassword {
if value != 0 {
t.Fatalf("borrowed password byte %d was not cleared after use", i)
}
}
}
func TestEnsureKeychainNewKeychainStillUnlocksAfterCreation(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
type lifecycleCall struct {
op string
path string
password []byte
}
var calls []lifecycleCall
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(path string, password []byte) error {
calls = append(calls, lifecycleCall{op: "create", path: path, password: append([]byte(nil), password...)})
return nil
}
unlockKeychainFile = func(path string, password []byte) error {
calls = append(calls, lifecycleCall{op: "unlock", path: path, password: append([]byte(nil), password...)})
return nil
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
keychainPath, err := ensureKeychain()
if err != nil {
t.Fatalf("ensureKeychain: %v", err)
}
if len(calls) != 2 || calls[0].op != "create" || calls[1].op != "unlock" {
t.Fatalf("lifecycle calls = %#v, want create then unlock", calls)
}
for _, call := range calls {
if call.path != keychainPath || len(call.password) != 64 {
t.Fatalf("lifecycle call = %#v, want path %q and generated 64-byte password", call, keychainPath)
}
}
if !reflect.DeepEqual(calls[0].password, calls[1].password) {
t.Fatal("create and unlock did not receive the same generated password")
}
}
func TestEnsureKeychainExistingUnlockErrorIsReturned(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, "Library", "Application Support", "lark-cli", "keysigner")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "lark-cli.keychain"), []byte("not-a-real-keychain"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "keychain.pass"), []byte("test-password\n"), 0600); err != nil {
t.Fatal(err)
}
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(string, []byte) error {
t.Fatal("createKeychainFile called for an existing keychain")
return nil
}
unlockKeychainFile = func(string, []byte) error {
return errors.New("keysigner: unlock keychain: Security framework status -25293")
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
_, err := ensureKeychain()
if err == nil {
t.Fatal("ensureKeychain returned nil error")
}
if got := err.Error(); !strings.Contains(got, "unlock keychain") || !strings.Contains(got, "-25293") {
t.Fatalf("ensureKeychain error = %q", got)
}
}
func TestEnsureKeychainCreateErrorStopsBeforeUnlock(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
previousCreate := createKeychainFile
previousUnlock := unlockKeychainFile
createKeychainFile = func(string, []byte) error {
return errors.New("keysigner: create keychain: Security framework status -50")
}
unlockKeychainFile = func(string, []byte) error {
t.Fatal("unlockKeychainFile called after creation failed")
return nil
}
t.Cleanup(func() {
createKeychainFile = previousCreate
unlockKeychainFile = previousUnlock
})
_, err := ensureKeychain()
if err == nil || !strings.Contains(err.Error(), "create keychain") || !strings.Contains(err.Error(), "-50") {
t.Fatalf("ensureKeychain error = %v", err)
}
}
// TestKeychainSignerRoundTrip creates a real non-extractable RSA key, signs, and
// verifies RS256 against the returned public key. Gated by LARK_KEYCHAIN_IT
// because it mutates the dedicated lark-cli keychain store. The signer is now
// cgo-free (purego runtime FFI), so it runs with CGO_ENABLED=0. Run with:
//
// LARK_KEYCHAIN_IT=1 go test -run RoundTrip ./internal/keysigner/
func TestKeychainSignerRoundTrip(t *testing.T) {
if os.Getenv("LARK_KEYCHAIN_IT") == "" {
t.Skip("set LARK_KEYCHAIN_IT=1 to run the macOS Keychain integration test")
}
t.Setenv("HOME", t.TempDir())
s := keychainSigner{}
ref := KeyRef{Label: "lark-cli-keychain-it"}
pub, err := s.EnsureKey(context.Background(), ref)
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
t.Fatalf("public key = %T, want *rsa.PublicKey", pub)
}
if alg, err := AlgForKey(pub); err != nil || alg != AlgRS256 {
t.Fatalf("AlgForKey = %q, %v; want RS256", alg, err)
}
input := []byte("header.payload")
sig, alg, err := s.Sign(context.Background(), ref, input)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if alg != AlgRS256 {
t.Errorf("Sign alg = %q, want RS256", alg)
}
h := sha256.Sum256(input)
if err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, h[:], sig); err != nil {
t.Errorf("RS256 signature did not verify: %v", err)
}
md, err := readKeyMetadata(ref.Label)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
appLabel, err := hex.DecodeString(md.AppLabel)
if err != nil {
t.Fatalf("decode app label: %v", err)
}
keychain, err := ensureKeychain()
if err != nil {
t.Fatalf("ensure keychain for export check: %v", err)
}
privateKeyRef, err := findPrivateKey(appLabel, keychain)
if err != nil {
t.Fatalf("find private key for export check: %v", err)
}
defer cfRelease(privateKeyRef)
var exportErr uintptr
if exported := secKeyCopyExternal(privateKeyRef, &exportErr); exported != 0 {
cfRelease(exported)
t.Fatal("private key was exportable")
}
if exportErr != 0 {
cfRelease(exportErr)
}
}

View File

@@ -1,135 +0,0 @@
//go:build linux || (windows && amd64)
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// TPM 2.0 signer (compiled into every linux and windows/amd64 build, no build
// tag required), backed by github.com/facebookincubator/sks.
//
// sks holds a non-exportable ECDSA P-256 key in the platform TPM and signs
// SHA-256 digests. On Linux it talks to /dev/tpmrm0; on Windows it uses the
// Microsoft Platform Crypto Provider (CNG). Both backends return an ASN.1 DER
// ECDSA signature, which we convert to the fixed-width r||s form JWS requires for
// ES256 (see ecdsaDERToJOSE). One key is created on the first private_key_jwt
// registration (DefaultKeyLabel) and reused for subsequent app registrations and
// every client_assertion on the same device.
//
// Excluded from windows/arm64: the sks Windows dependency stack (go-ole) has no
// arm64 VARIANT and fails to compile, so windows/arm64 falls back to
// client_secret only (keysigner.Active() is nil). On darwin the keychain signer
// is used instead. CGO is never required.
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/sha256"
"fmt"
"io"
"github.com/facebookincubator/flog"
"github.com/facebookincubator/sks"
)
// p256ByteLen is the P-256 coordinate width. sks regular keys are always ECDSA
// P-256, so ES256 signatures are 2*p256ByteLen bytes of r||s.
const p256ByteLen = 32
// keyTag is the sks key tag. Both the Linux and Windows sks backends address
// keys by label and ignore the tag, but the macOS backend uses it, so we set a
// stable namespaced value for forward compatibility.
const keyTag = "com.larksuite.cli"
// sksSigner implements Signer (and HardwareProber) using a non-exportable
// TPM 2.0 ECDSA key via sks.
type sksSigner struct{}
func init() {
Register(sksSigner{})
// This sks version logs verbose TPM-operation chatter to stderr via flog (a
// glog fork it owns exclusively) — e.g. "Loaded TPM device", "Found handle
// for key" on every sign. The CLI does not use flog, so silence it
// process-wide here; real failures are returned as errors, never relied upon
// from these logs. (Newer sks switched to slog, but that lands only on its
// go-1.24 line, which we avoid to keep the module on go 1.23.)
flog.SetOutput(io.Discard)
}
// EnsureKey returns the public key for ref, creating the TPM key if absent.
// sks.NewKey is find-or-create: it returns the existing key when one is present.
func (sksSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
if err != nil {
return nil, fmt.Errorf("keysigner: ensure TPM key %q: %w", ref.Label, err)
}
defer key.Close()
return ecdsaPublic(ref.Label, key.Public())
}
// PublicKey returns the public key for ref without creating it. FromLabelTag does
// not touch the TPM until Public() loads the sealed key; a missing key yields a
// nil public key, which we surface as an error — at runtime the key MUST already
// exist (it was bound to the app at registration), so we never silently mint a
// new, unbound one here.
func (sksSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
pub := sks.FromLabelTag(ref.Label).Public()
if pub == nil {
return nil, fmt.Errorf("keysigner: TPM key %q not found", ref.Label)
}
return ecdsaPublic(ref.Label, pub)
}
// Sign signs signingInput with the TPM key and returns a JOSE-format ES256
// signature (fixed-width r||s) plus its alg.
func (sksSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
if err != nil {
return nil, "", fmt.Errorf("keysigner: load TPM key %q: %w", ref.Label, err)
}
defer key.Close()
// ES256 signs the SHA-256 digest of the JWS signing input.
digest := sha256.Sum256(signingInput)
der, err := key.Sign(nil, digest[:], crypto.SHA256)
if err != nil {
return nil, "", fmt.Errorf("keysigner: TPM sign with key %q: %w", ref.Label, err)
}
// Both sks backends emit ASN.1 DER; JWS ES256 requires fixed-width r||s
// (RFC 7518 §3.4).
rs, err := ecdsaDERToJOSE(der, p256ByteLen)
if err != nil {
return nil, "", err
}
return rs, AlgES256, nil
}
// ProbeHardware reports on the TPM backing this signer without touching any key.
// A failure to reach the TPM (no device, permission denied, not TPM 2.0) is
// reported as Available=false with Reason set, NOT as a Go error — the probe
// still succeeded in determining that the TEE is currently unusable.
func (sksSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
info := HardwareInfo{Backend: "tpm2"}
data, err := sks.GetSecureHardwareVendorData()
if err != nil {
info.Reason = cleanProbeError(err)
return info, nil
}
info.VendorName = data.VendorName
info.VendorInfo = data.VendorInfo
info.Available = data.IsTPM20CompliantDevice
if !info.Available {
info.Reason = "secure hardware is not a TPM 2.0 compliant device"
}
return info, nil
}
// ecdsaPublic asserts that an sks public key is an ECDSA key (it always is for
// regular sks keys) so the caller gets the concrete type AlgForKey/PublicKeyJWK expect.
func ecdsaPublic(label string, pub crypto.PublicKey) (*ecdsa.PublicKey, error) {
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("keysigner: TPM key %q public is %T, want *ecdsa.PublicKey", label, pub)
}
return ecPub, nil
}

View File

@@ -1,122 +0,0 @@
//go:build linux || (windows && amd64)
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/sha256"
"io"
"math/big"
"strings"
"testing"
"github.com/facebookincubator/flog"
"github.com/facebookincubator/sks"
)
// TestFlogSilenced verifies the mechanism init() relies on to keep sks's flog
// TPM chatter off the CLI's stderr: SetOutput redirects flog, and io.Discard
// drops it. Cleanup restores io.Discard so init()'s silencing holds for the
// rest of the package's tests.
func TestFlogSilenced(t *testing.T) {
var buf bytes.Buffer
flog.SetOutput(&buf)
t.Cleanup(func() { flog.SetOutput(io.Discard) })
flog.Info("captured-line")
if !strings.Contains(buf.String(), "captured-line") {
t.Fatalf("flog.SetOutput(buffer) did not capture output: %q", buf.String())
}
flog.SetOutput(io.Discard)
buf.Reset()
flog.Info("should-be-discarded")
if buf.Len() != 0 {
t.Errorf("flog output not discarded: %q", buf.String())
}
}
// requireTEE skips the test unless the TPM is present and usable. On a Linux
// machine with a TPM but a restrictive device owner (`/dev/tpmrm0` is `tss:tss`
// by default), grant access with `sudo usermod -aG tss $USER` then re-login, or
// run the test under sudo.
func requireTEE(t *testing.T) {
t.Helper()
info, err := sksSigner{}.ProbeHardware(context.Background())
if err != nil || !info.Available {
reason := info.Reason
if err != nil {
reason = err.Error()
}
t.Skipf("TEE not available (%s)", reason)
}
}
// TestSKSSignerRoundTrip exercises the full registration→assertion contract
// against the real TPM: create the key, read it back without creating, derive
// the JWS alg + JWK, sign, and verify the fixed-width r||s output.
func TestSKSSignerRoundTrip(t *testing.T) {
requireTEE(t)
var s sksSigner
ctx := context.Background()
ref := KeyRef{Label: "larksuite-cli-test"}
// Best-effort cleanup so the test key does not linger in the TPM-sealed store.
t.Cleanup(func() {
if k, err := sks.NewKey(ref.Label, keyTag, false, true, nil); err == nil {
_ = k.Remove()
_ = k.Close()
}
})
pub, err := s.EnsureKey(ctx, ref)
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
t.Fatalf("EnsureKey returned %T, want *ecdsa.PublicKey", pub)
}
// PublicKey (no-create) must return the same key bound at EnsureKey.
pub2, err := s.PublicKey(ctx, ref)
if err != nil {
t.Fatalf("PublicKey: %v", err)
}
if !ecPub.Equal(pub2) {
t.Fatal("PublicKey returned a different key than EnsureKey")
}
// The JWT layer derives alg + JWK from the public key; both must work.
if alg, err := AlgForKey(pub); err != nil || alg != AlgES256 {
t.Fatalf("AlgForKey = %q, %v; want ES256", alg, err)
}
if _, err := PublicKeyJWK(pub); err != nil {
t.Fatalf("PublicKeyJWK: %v", err)
}
// Sign a representative JWS signing input and verify the converted r||s.
input := []byte("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJjbGkifQ")
sig, alg, err := s.Sign(ctx, ref, input)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if alg != AlgES256 {
t.Fatalf("Sign alg = %q, want ES256", alg)
}
if len(sig) != 2*p256ByteLen {
t.Fatalf("len(sig) = %d, want %d (fixed-width r||s)", len(sig), 2*p256ByteLen)
}
digest := sha256.Sum256(input)
r := new(big.Int).SetBytes(sig[:p256ByteLen])
ss := new(big.Int).SetBytes(sig[p256ByteLen:])
if !ecdsa.Verify(ecPub, digest[:], r, ss) {
t.Fatal("TPM signature did not verify against the public key")
}
}

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

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