From f03138b9f0fd7a497d41201d808d0562a60e8855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=B3=E4=BC=AF?= Date: Fri, 15 May 2026 14:38:18 +0800 Subject: [PATCH 01/61] feat(wiki): add +space-list / +node-list / +node-copy shortcuts (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce three new wiki shortcuts that wrap the corresponding raw APIs with structured flags, formatted output, my_library alias handling, and unified envelope shape, replacing the bare `lark-cli wiki spaces list` / `wiki nodes list` / `wiki nodes copy` flows for the common cases. Shortcuts - wiki +space-list (read, scopes: wiki:space:retrieve): lists wiki spaces. Default fetches a single page; --page-all walks every page capped by --page-limit (default 10, 0 = unlimited). Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson. Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode distinguishes "no spaces" from "empty page with has_more" and hints the caller to resume. - wiki +node-list (read, scopes: wiki:node:retrieve): lists nodes in a space or under a parent. Same pagination + format story as +space-list. Accepts the my_library alias for --space-id with --as user (resolved via a shared resolveMyLibrarySpaceID helper extracted from +node-create); rejects my_library upfront for --as bot. - wiki +node-copy (high-risk-write, scopes: wiki:node:copy): copies a node into a target space or parent. --target-space-id and --target-parent-node-token are mutually exclusive. Risk is marked high-risk-write to match the upstream API's danger: true flag, so the framework requires --yes. Source is preserved; subtree is copied. Both list shortcuts pick the narrowest scope the upstream API accepts. The framework's preflight (internal/auth/scope.go MissingScopes) does exact-string scope matching, so declaring the broader wiki:wiki:readonly form would wrongly reject tokens that carry only the per-API scope — which the API itself accepts — and emit a misleading missing-scope hint. Shared changes - shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID so +node-list and +node-create share one my_library resolution path. - shortcuts/wiki/shortcuts.go: register the three new shortcuts. - skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list, node-copy}.md: documentation for the new shortcuts. Tooling - scripts/check-doc-tokens.sh + Makefile gitleaks target: pre-commit check that scans skill reference docs for realistic-looking Lark token values without the _EXAMPLE_TOKEN placeholder convention, preventing gitleaks false positives. - .gitleaks.toml: allowlist tuning. - .gitignore: ignore .tmp/. Tests - shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry membership, declared-narrow-scope pinning, flag validation (page-size range, page-limit >= 0, target flag exclusivity, my_library + bot rejection), auto-pagination merging, --page-limit truncation surfacing next cursor, --page-token single-page mode, empty-slice serialisation, has_more hint pretty rendering, my_library user-path resolution, +node-copy copy-to-space / copy-to-parent + body shape, pretty rendering, and the high-risk-write --yes gate. - tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end workflow exercising the shortcut layer against a real tenant. Reuses an existing my_library node as a host so the test never adds to the top-layer quota; the copy is placed under the same host node. - tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added. Minor cleanups - skills/lark-doc/references/lark-doc-search.md and skills/lark-minutes/references/lark-minutes-search.md: replace realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so scripts/check-doc-tokens.sh passes. Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253 Co-authored-by: liujinkun --- .gitignore | 1 + .gitleaks.toml | 1 + Makefile | 14 +- scripts/check-doc-tokens.sh | 66 ++ shortcuts/wiki/shortcuts.go | 3 + shortcuts/wiki/wiki_list_copy_test.go | 973 ++++++++++++++++++ shortcuts/wiki/wiki_node_copy.go | 140 +++ shortcuts/wiki/wiki_node_create.go | 19 + shortcuts/wiki/wiki_node_create_test.go | 4 +- shortcuts/wiki/wiki_node_list.go | 218 ++++ shortcuts/wiki/wiki_space_list.go | 211 ++++ skills/lark-doc/references/lark-doc-search.md | 10 +- .../references/lark-minutes-search.md | 2 +- skills/lark-wiki/SKILL.md | 4 + .../references/lark-wiki-node-copy.md | 72 ++ .../references/lark-wiki-node-list.md | 88 ++ .../references/lark-wiki-space-list.md | 68 ++ tests/cli_e2e/wiki/coverage.md | 11 +- .../wiki/wiki_shortcut_workflow_test.go | 269 +++++ 19 files changed, 2161 insertions(+), 13 deletions(-) create mode 100755 scripts/check-doc-tokens.sh create mode 100644 shortcuts/wiki/wiki_list_copy_test.go create mode 100644 shortcuts/wiki/wiki_node_copy.go create mode 100644 shortcuts/wiki/wiki_node_list.go create mode 100644 shortcuts/wiki/wiki_space_list.go create mode 100644 skills/lark-wiki/references/lark-wiki-node-copy.md create mode 100644 skills/lark-wiki/references/lark-wiki-node-list.md create mode 100644 skills/lark-wiki/references/lark-wiki-space-list.md create mode 100644 tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go diff --git a/.gitignore b/.gitignore index 90313e480..dc576a2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ cmd/api/download.bin app.log /sidecar-server-demo /server-demo +.tmp/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 597b33952..8dbe4165f 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -14,3 +14,4 @@ id = "lark-session-token" description = "Detect Lark session tokens" regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b''' keywords = ["XN0YXJ0-", "-WVuZA"] + diff --git a/Makefile b/Makefile index 7d78c510b..7733335b4 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,9 @@ DATE := $(shell date +%Y-%m-%d) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local -.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta +.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks + +all: test fetch_meta: python3 scripts/fetch_meta.py @@ -37,3 +39,13 @@ uninstall: clean: rm -f $(BINARY) + +# Run secret-leak checks locally before pushing. +# Step 1: check-doc-tokens catches realistic-looking example tokens in reference +# docs and asks you to use _EXAMPLE_TOKEN placeholders instead. +# Step 2: gitleaks scans the full repo for real leaked secrets. +# Install gitleaks: https://github.com/gitleaks/gitleaks#installing +gitleaks: + @bash scripts/check-doc-tokens.sh + @command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; } + gitleaks detect --redact -v --exit-code=2 diff --git a/scripts/check-doc-tokens.sh b/scripts/check-doc-tokens.sh new file mode 100755 index 000000000..a02c8f140 --- /dev/null +++ b/scripts/check-doc-tokens.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# check-doc-tokens.sh +# +# Scans skill reference docs for token-like values that look realistic but +# are not using the required placeholder format (*_EXAMPLE_TOKEN or similar). +# +# Real token patterns (Lark API) often look like: +# wikcnXXXXXXXXX doccnXXXXXXX shtcnXXX fldcnXXX ou_XXXX cli_XXXX +# +# Docs MUST use clearly fake placeholders, e.g.: +# wikcn_EXAMPLE_TOKEN doccn_EXAMPLE_TOKEN your_token_here +# +# If this check fails, replace the realistic-looking value with a placeholder +# like `wikcn_EXAMPLE_TOKEN` so gitleaks CI won't flag it as a real secret. + +set -euo pipefail + +SKILLS_DIR="${1:-skills}" +ERRORS=0 + +# Patterns that indicate a realistic-looking Lark token value. +# Three forms are detected: +# 1. JSON-style quoted strings: "field": "token_value" +# 2. Markdown backtick spans: `token_value` +# 3. Bare tokens: --flag wikcnABC123 (e.g. inside fenced code blocks) +# +# Token prefixes used by Lark Open Platform: +# wikcn doccn docx shtcn bascn fldcn vewcn tbln ou_ cli_ obcn flec +# +# Excluded (clearly fake, matched by PLACEHOLDER_RE below): +# - Values containing EXAMPLE / _TOKEN / XXXX / your_ / _here +# - Angle-bracket placeholders +# Require at least one digit in the suffix — real API tokens are always alphanumeric +# with digits. Pure-letter suffixes (e.g. ou_manager, ou_director) are clearly fake names. +PREFIXES='(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)' +TOKEN_BODY="${PREFIXES}"'[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}' +REALISTIC_TOKEN_RE="\"${TOKEN_BODY}\"|\`${TOKEN_BODY}\`|\\b${TOKEN_BODY}\\b" +PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)' + +while IFS= read -r -d '' file; do + # grep returns exit 1 when no match — use || true to avoid set -e killing us + # Then filter out values that are clearly placeholders (EXAMPLE, XXXX, etc.) + matches=$(grep -nEo "$REALISTIC_TOKEN_RE" "$file" 2>/dev/null | grep -vE "$PLACEHOLDER_RE" || true) + if [[ -n "$matches" ]]; then + echo "" + echo "❌ $file" + echo " Contains realistic-looking token values that may trigger gitleaks:" + while IFS= read -r line; do + echo " $line" + done <<< "$matches" + echo " → Replace with a placeholder, e.g.: wikcn_EXAMPLE_TOKEN, doccn_EXAMPLE_TOKEN" + ERRORS=$((ERRORS + 1)) + fi +done < <(find "$SKILLS_DIR" -path "*/references/*.md" -print0) + +if [[ $ERRORS -gt 0 ]]; then + echo "" + echo "❌ check-doc-tokens: $ERRORS file(s) contain realistic token values in reference docs." + echo " Use _EXAMPLE_TOKEN placeholders to avoid false positives in gitleaks CI." + exit 1 +else + echo "✅ check-doc-tokens: all reference docs use safe placeholder tokens." +fi diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go index f22be7807..da5b388d0 100644 --- a/shortcuts/wiki/shortcuts.go +++ b/shortcuts/wiki/shortcuts.go @@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut { WikiMove, WikiNodeCreate, WikiDeleteSpace, + WikiSpaceList, + WikiNodeList, + WikiNodeCopy, } } diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go new file mode 100644 index 000000000..6c9cf07d4 --- /dev/null +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -0,0 +1,973 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "encoding/json" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// ── +space-list ────────────────────────────────────────────────────────────── + +func TestWikiShortcutsIncludesSpaceListNodeListNodeCopy(t *testing.T) { + t.Parallel() + + commands := map[string]bool{} + for _, s := range Shortcuts() { + commands[s.Command] = true + } + for _, want := range []string{"+space-list", "+node-list", "+node-copy"} { + if !commands[want] { + t.Errorf("Shortcuts() missing %q", want) + } + } +} + +// TestWikiListShortcutsDeclareNarrowScopes pins the per-endpoint scope +// choice. The framework's preflight does exact string matching, so a broad +// scope (e.g. wiki:wiki:readonly) would wrongly reject tokens carrying only +// the narrow per-API scope that the API actually accepts. +func TestWikiListShortcutsDeclareNarrowScopes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + shortcut common.Shortcut + want []string + }{ + {"+space-list", WikiSpaceList, []string{"wiki:space:retrieve"}}, + {"+node-list", WikiNodeList, []string{"wiki:node:retrieve"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if !reflect.DeepEqual(tc.shortcut.Scopes, tc.want) { + t.Fatalf("%s scopes = %v, want %v", tc.name, tc.shortcut.Scopes, tc.want) + } + }) + } +} + +func TestWikiSpaceListReturnsPaginatedSpaces(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_1", + "name": "Engineering Wiki", + "space_type": "team", + }, + map[string]interface{}{ + "space_id": "space_2", + "name": "Personal Library", + "space_type": "my_library", + }, + }, + }, + "msg": "success", + }, + }) + + err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Spaces []map[string]interface{} `json:"spaces"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + Meta struct { + Count float64 `json:"count"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if !envelope.OK { + t.Fatalf("expected ok=true, got %s", stdout.String()) + } + if envelope.Meta.Count != 2 { + t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count) + } + if envelope.Data.HasMore { + t.Fatalf("has_more = true, want false on natural end") + } + if envelope.Data.Spaces[0]["name"] != "Engineering Wiki" { + t.Fatalf("spaces[0].name = %v, want %q", envelope.Data.Spaces[0]["name"], "Engineering Wiki") + } +} + +// ── +node-list ─────────────────────────────────────────────────────────────── + +func TestWikiNodeListRequiresSpaceID(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeList, []string{"+node-list", "--as", "user"}, factory, nil) + if err == nil || !strings.Contains(err.Error(), "required") { + t.Fatalf("expected required flag error, got %v", err) + } +} + +func TestWikiNodeListReturnsNodesForSpace(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_node_1", + "obj_token": "docx_1", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Getting Started", + "has_child": true, + }, + map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_node_2", + "obj_token": "docx_2", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Architecture", + "has_child": false, + }, + }, + }, + "msg": "success", + }, + }) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "space_123", "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Nodes []map[string]interface{} `json:"nodes"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + Meta struct { + Count float64 `json:"count"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if !envelope.OK { + t.Fatalf("expected ok=true, got %s", stdout.String()) + } + if envelope.Meta.Count != 2 { + t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count) + } + if envelope.Data.Nodes[0]["title"] != "Getting Started" { + t.Fatalf("nodes[0].title = %v, want %q", envelope.Data.Nodes[0]["title"], "Getting Started") + } + if envelope.Data.Nodes[0]["has_child"] != true { + t.Fatalf("nodes[0].has_child = %v, want true", envelope.Data.Nodes[0]["has_child"]) + } +} + +func TestWikiNodeListPassesParentNodeToken(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_child", + "obj_token": "docx_child", + "obj_type": "docx", + "parent_node_token": "wik_parent", + "node_type": "origin", + "title": "Child Doc", + "has_child": false, + }, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + // Verify the correct node was returned (parent_node_token was passed correctly). + var envelope struct { + OK bool `json:"ok"` + Data struct { + Nodes []map[string]interface{} `json:"nodes"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if !envelope.OK { + t.Fatalf("expected ok=true, got %s", stdout.String()) + } + if len(envelope.Data.Nodes) != 1 { + t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes)) + } + if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" { + t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent") + } +} + +func TestWikiNodeListRejectsMyLibraryForBot(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "my_library", "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") { + t.Fatalf("expected my_library bot rejection, got %v", err) + } +} + +func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + // Step 1: resolve my_library to the real space_id. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/my_library", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "space": map[string]interface{}{ + "space_id": "space_personal_42", + "name": "My Library", + "space_type": "my_library", + }, + }, + }, + }) + // Step 2: list nodes in the resolved space. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_personal_42", + "node_token": "wik_personal_1", + "title": "Personal Note", + }, + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "my_library", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Nodes []map[string]interface{} `json:"nodes"` + } `json:"data"` + Meta struct { + Count float64 `json:"count"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Meta.Count != 1 { + t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count) + } + if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" { + t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"]) + } +} + +// ── +node-copy ─────────────────────────────────────────────────────────────── + +func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", "--space-id", "space_123", "--node-token", "wik_src", "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") { + t.Fatalf("expected target validation error, got %v", err) + } +} + +func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", "--space-id", "space_123", "--node-token", "wik_src", + "--target-space-id", "space_dst", "--target-parent-node-token", "wik_parent", + "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutually exclusive error, got %v", err) + } +} + +// TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write +// contract: invocation without --yes must fail with a confirmation_required +// error and must NOT issue the underlying API call. The aligned upstream +// schema flags this API as `danger: true`, and the shortcut now matches that +// risk classification. +func TestWikiNodeCopyDeclaredHighRiskWrite(t *testing.T) { + t.Parallel() + + if WikiNodeCopy.Risk != "high-risk-write" { + t.Fatalf("WikiNodeCopy.Risk = %q, want %q", WikiNodeCopy.Risk, "high-risk-write") + } + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + // No HTTP stub registered — if the gate leaks, the request fires and + // httpmock errors with "no stub for POST ..." instead of the expected + // confirmation_required error, making the regression obvious. + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-space-id", "space_dst", + "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation_required error, got %v", err) + } +} + +func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_copied", + "obj_token": "docx_copied", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Architecture (Copy)", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-space-id", "space_dst", + "--title", "Architecture (Copy)", + "--yes", + "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if !envelope.OK { + t.Fatalf("expected ok=true, got %s", stdout.String()) + } + if envelope.Data["node_token"] != "wik_copied" { + t.Fatalf("node_token = %v, want %q", envelope.Data["node_token"], "wik_copied") + } + if envelope.Data["space_id"] != "space_dst" { + t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst") + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("unmarshal captured body: %v", err) + } + if captured["target_space_id"] != "space_dst" { + t.Fatalf("captured target_space_id = %v, want %q", captured["target_space_id"], "space_dst") + } + if captured["title"] != "Architecture (Copy)" { + t.Fatalf("captured title = %v, want %q", captured["title"], "Architecture (Copy)") + } + if got := stderr.String(); !strings.Contains(got, "Copying wiki node") { + t.Fatalf("stderr = %q, want copy message", got) + } +} + +func TestWikiNodeCopyCopiesNodeToTargetParent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_src", + "node_token": "wik_copied2", + "obj_token": "docx_copied2", + "obj_type": "docx", + "parent_node_token": "wik_parent_dst", + "node_type": "origin", + "title": "Architecture", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-parent-node-token", "wik_parent_dst", + "--yes", + "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("unmarshal captured body: %v", err) + } + if captured["target_parent_token"] != "wik_parent_dst" { + t.Fatalf("captured target_parent_token = %v, want %q", captured["target_parent_token"], "wik_parent_dst") + } + if _, hasTitle := captured["title"]; hasTitle { + t.Fatalf("title should not be in body when --title not provided, got %v", captured) + } +} + +// ── +space-list / +node-list pagination & format ───────────────────────────── + +func TestWikiSpaceListRejectsInvalidPageSize(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiSpaceList, []string{ + "+space-list", "--page-size", "0", "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "--page-size must be between 1 and 50") { + t.Fatalf("expected page-size validation error, got %v", err) + } +} + +func TestWikiSpaceListRejectsNegativePageLimit(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiSpaceList, []string{ + "+space-list", "--page-limit", "-1", "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "--page-limit must be a non-negative integer") { + t.Fatalf("expected page-limit validation error, got %v", err) + } +} + +func TestWikiSpaceListAutoPaginatesAcrossPages(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + // Page 1: has_more=true, page_token set. Loop must continue. + page1 := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": true, + "page_token": "tok_page2", + "items": []interface{}{ + map[string]interface{}{"space_id": "sp_1", "name": "First"}, + }, + }, + }, + } + // Page 2: must receive page_token=tok_page2 in query. Captured to verify. + var page2Query string + page2 := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + OnMatch: func(req *http.Request) { page2Query = req.URL.RawQuery }, + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{"space_id": "sp_2", "name": "Second"}, + }, + }, + }, + } + reg.Register(page1) + reg.Register(page2) + + err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--page-all", "--as", "bot"}, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + Data struct { + Spaces []map[string]interface{} `json:"spaces"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + Meta struct { + Count float64 `json:"count"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Meta.Count != 2 || len(envelope.Data.Spaces) != 2 { + t.Fatalf("merged spaces = %d / count=%v, want 2 / 2", len(envelope.Data.Spaces), envelope.Meta.Count) + } + if envelope.Data.HasMore || envelope.Data.PageToken != "" { + t.Fatalf("natural end should clear has_more/page_token, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken) + } + q, _ := url.ParseQuery(page2Query) + if q.Get("page_token") != "tok_page2" { + t.Fatalf("page2 page_token = %q, want tok_page2", q.Get("page_token")) + } +} + +func TestWikiSpaceListPageLimitTruncatesAndExposesNextCursor(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + // Only stub page 1; with --page-limit=1, the loop must stop BEFORE + // requesting page 2 — and surface has_more/page_token so the caller can resume. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": true, + "page_token": "tok_next", + "items": []interface{}{ + map[string]interface{}{"space_id": "sp_only", "name": "First"}, + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiSpaceList, []string{ + "+space-list", "--page-all", "--page-limit", "1", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + Data struct { + Spaces []map[string]interface{} `json:"spaces"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if len(envelope.Data.Spaces) != 1 { + t.Fatalf("spaces = %d, want 1 (capped)", len(envelope.Data.Spaces)) + } + if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" { + t.Fatalf("truncated state = has_more=%v page_token=%q, want true / tok_next", envelope.Data.HasMore, envelope.Data.PageToken) + } +} + +func TestWikiSpaceListExplicitPageTokenStopsAfterOnePage(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + // Stub a page where has_more=true; auto-pagination should NOT trigger + // because the caller supplied an explicit --page-token cursor. + var capturedQuery string + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + OnMatch: func(req *http.Request) { capturedQuery = req.URL.RawQuery }, + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": true, + "page_token": "tok_next", + "items": []interface{}{map[string]interface{}{"space_id": "sp_x"}}, + }, + }, + }) + + err := mountAndRunWiki(t, WikiSpaceList, []string{ + "+space-list", "--page-token", "tok_input", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + q, _ := url.ParseQuery(capturedQuery) + if q.Get("page_token") != "tok_input" { + t.Fatalf("captured page_token = %q, want tok_input", q.Get("page_token")) + } +} + +func TestWikiSpaceListPrettyFormatRendersFields(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "sp_1", + "name": "Engineering", + "description": "team docs", + "space_type": "team", + "visibility": "public", + "open_sharing": "open", + }, + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiSpaceList, []string{ + "+space-list", "--format", "pretty", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + out := stdout.String() + for _, want := range []string{ + "Engineering", + "space_id: sp_1", + "space_type: team", + "visibility: public", + "description: team docs", + } { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q, got:\n%s", want, out) + } + } +} + +func TestWikiNodeListDefaultIsSinglePage(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + // Only one stub registered; if the default tried to auto-paginate, the + // loop would attempt a 2nd request and httpmock would error. So this + // test pins down the "default = single page" contract. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": true, + "page_token": "tok_next", + "items": []interface{}{ + map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"}, + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "space_123", "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + Data struct { + Nodes []map[string]interface{} `json:"nodes"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if len(envelope.Data.Nodes) != 1 { + t.Fatalf("nodes = %d, want 1 (single page default)", len(envelope.Data.Nodes)) + } + if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" { + t.Fatalf("single-page default should surface upstream cursor, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken) + } +} + +func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_1", + "obj_type": "docx", + "obj_token": "docx_1", + "title": "Getting Started", + "has_child": true, + }, + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + out := stdout.String() + for _, want := range []string{ + "Getting Started", + "node_token: wik_1", + "obj_type: docx", + "has_child: true", + } { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q, got:\n%s", want, out) + } + } +} + +// ── QA-driven fixes: empty slice + has_more hint + node-copy format ── + +func TestWikiSpaceListEmptyResultReturnsEmptySliceNotNull(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{}, + }, + }, + }) + + err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + // Substring assertion is the only reliable way to distinguish [] from null + // in serialised JSON — unmarshalling both back into a Go slice would + // collapse the distinction. + if !strings.Contains(stdout.String(), `"spaces": []`) { + t.Fatalf("expected spaces to be empty array [], got:\n%s", stdout.String()) + } + if strings.Contains(stdout.String(), `"spaces": null`) { + t.Fatalf("spaces serialised as null — JSON consumers expect []:\n%s", stdout.String()) + } + + var envelope struct { + Meta struct { + Count float64 `json:"count"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Meta.Count != 0 { + t.Fatalf("meta.count = %v, want 0", envelope.Meta.Count) + } +} + +func TestWikiSpaceListPrettyHintsWhenEmptyButHasMore(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": true, + "page_token": "tok_more", + "items": []interface{}{}, + }, + }, + }) + + err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--format", "pretty", "--as", "bot"}, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + out := stdout.String() + // When the bot's first page is filtered out by upstream permissions, the + // blanket "No wiki spaces found." used to mislead users into thinking they + // had no access at all. Pretty mode must now distinguish that case. + if strings.Contains(out, "No wiki spaces found.") { + t.Fatalf("pretty output should not flatly claim 'No wiki spaces found.' when has_more=true; got:\n%s", out) + } + for _, want := range []string{ + "Current page is empty but the server reports more pages.", + "tok_more", + } { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q, got:\n%s", want, out) + } + } +} + +func TestWikiNodeCopyHasFormatPrettyRendersNode(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_copied", + "obj_token": "docx_copied", + "obj_type": "docx", + "parent_node_token": "wik_parent", + "node_type": "origin", + "title": "Architecture (Copy)", + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-space-id", "space_dst", + "--title", "Architecture (Copy)", + "--format", "pretty", + "--yes", + "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + out := stdout.String() + for _, want := range []string{ + "Copied node:", + "title: Architecture (Copy)", + "node_token: wik_copied", + "space_id: space_dst", + "parent_node_token: wik_parent", + } { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q, got:\n%s", want, out) + } + } +} diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go new file mode 100644 index 000000000..5a4a97d00 --- /dev/null +++ b/shortcuts/wiki/wiki_node_copy.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// WikiNodeCopy copies a wiki node into a target space or under a target parent node. +var WikiNodeCopy = common.Shortcut{ + Service: "wiki", + Command: "+node-copy", + Description: "Copy a wiki node to a target space or parent node", + Risk: "high-risk-write", + Scopes: []string{"wiki:node:copy"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "space-id", Desc: "source wiki space ID", Required: true}, + {Name: "node-token", Desc: "source node token to copy", Required: true}, + {Name: "target-space-id", Desc: "target wiki space ID; required if --target-parent-node-token is not set"}, + {Name: "target-parent-node-token", Desc: "target parent node token; required if --target-space-id is not set"}, + {Name: "title", Desc: "new title for the copied node; leave empty to keep the original title"}, + }, + Tips: []string{ + "At least one of --target-space-id or --target-parent-node-token must be provided.", + "Omit --title to keep the original node title in the copy.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("space-id")), "--space-id"); err != nil { + return err + } + if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("node-token")), "--node-token"); err != nil { + return err + } + targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id")) + targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token")) + if targetSpaceID == "" && targetParent == "" { + return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required") + } + if targetSpaceID != "" && targetParent != "" { + return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one") + } + if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil { + return err + } + return validateOptionalResourceName(targetParent, "--target-parent-node-token") + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + nodeToken := strings.TrimSpace(runtime.Str("node-token")) + return common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(nodeToken))). + Body(buildNodeCopyBody(runtime)). + Set("space_id", spaceID). + Set("node_token", nodeToken) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + nodeToken := strings.TrimSpace(runtime.Str("node-token")) + + fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n", + common.MaskToken(nodeToken), common.MaskToken(spaceID)) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(nodeToken)), + nil, buildNodeCopyBody(runtime)) + if err != nil { + return err + } + + node, err := parseWikiNodeRecord(common.GetMap(data, "node")) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n", + common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID)) + out := wikiNodeCopyOutput(node) + runtime.OutFormat(out, nil, func(w io.Writer) { + renderWikiNodeCopyPretty(w, out) + }) + return nil + }, +} + +func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) { + fmt.Fprintf(w, "Copied node:\n") + fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"])) + fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"])) + fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"])) + fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"])) + fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"])) + if parent, _ := out["parent_node_token"].(string); parent != "" { + fmt.Fprintf(w, " parent_node_token: %s\n", parent) + } +} + +func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} { + // Validate has already rejected the case where both --target-space-id and + // --target-parent-node-token are set (mutually exclusive). It is safe to + // inline both flags here; do not loosen that check without revisiting this + // body builder, or the upstream API will see an ambiguous request shape. + body := map[string]interface{}{} + if v := strings.TrimSpace(runtime.Str("target-space-id")); v != "" { + body["target_space_id"] = v + } + if v := strings.TrimSpace(runtime.Str("target-parent-node-token")); v != "" { + body["target_parent_token"] = v + } + if v := strings.TrimSpace(runtime.Str("title")); v != "" { + body["title"] = v + } + return body +} + +func wikiNodeCopyOutput(node *wikiNodeRecord) map[string]interface{} { + return map[string]interface{}{ + "space_id": node.SpaceID, + "node_token": node.NodeToken, + "obj_token": node.ObjToken, + "obj_type": node.ObjType, + "node_type": node.NodeType, + "title": node.Title, + "parent_node_token": node.ParentNodeToken, + "has_child": node.HasChild, + } +} diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index 639fdddf5..d9fd7e5ad 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -413,6 +413,25 @@ func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) { return "", output.ErrValidation("personal document library was not found, please specify --space-id") } +// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns +// the per-user real space_id. Shared by shortcuts that accept the my_library +// alias (e.g. +node-create, +node-list) so the behavior stays consistent. +func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) { + data, err := runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)), + nil, nil, + ) + if err != nil { + return "", err + } + space, err := parseWikiSpaceRecord(common.GetMap(data, "space")) + if err != nil { + return "", err + } + return requireWikiSpaceID(space) +} + func validateOptionalResourceName(value, flagName string) error { if value == "" { return nil diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index a057c25d9..df22f9008 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -111,8 +111,8 @@ func TestWikiShortcutsIncludeAllCommands(t *testing.T) { t.Parallel() shortcuts := Shortcuts() - if len(shortcuts) != 3 { - t.Fatalf("len(Shortcuts()) = %d, want 3", len(shortcuts)) + if len(shortcuts) != 6 { + t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts)) } if shortcuts[0].Command != "+move" { t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move") diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go new file mode 100644 index 000000000..c743f2320 --- /dev/null +++ b/shortcuts/wiki/wiki_node_list.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + wikiNodeListDefaultPageSize = 50 + wikiNodeListMaxPageSize = 50 +) + +// WikiNodeList lists child nodes in a wiki space or under a parent node. +var WikiNodeList = common.Shortcut{ + Service: "wiki", + Command: "+node-list", + Description: "List wiki nodes in a space or under a parent node", + Risk: "read", + // Same exact-match-scope reasoning as +space-list: declare the + // narrowest scope the upstream API accepts so we don't false-reject + // tokens that only carry wiki:node:retrieve. + Scopes: []string{"wiki:node:retrieve"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library, or +space-list to discover other space IDs", Required: true}, + {Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"}, + {Name: "page-size", Type: "int", Default: strconv.Itoa(wikiNodeListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiNodeListMaxPageSize)}, + {Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"}, + {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"}, + }, + Tips: []string{ + "Default fetches a single page; pass --page-all to walk every page (large knowledge bases can be huge — keep an eye on --page-limit).", + "Use --parent-node-token to drill into a sub-directory.", + "Run +space-list first to discover your space IDs, including the personal document library.", + "--space-id my_library is a per-user alias and is only valid with --as user.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + // my_library is a per-user personal-library alias; it has no meaning + // for a tenant_access_token (--as bot), so reject early with a clear + // hint instead of deferring to API-time errors. Matches the contract + // used by +node-create and +move. + if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID { + return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id") + } + if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil { + return err + } + if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil { + return err + } + return validateWikiListPagination(runtime, wikiNodeListMaxPageSize) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + params := map[string]interface{}{"page_size": runtime.Int("page-size")} + if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" { + params["parent_node_token"] = pt + } + if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { + params["page_token"] = pt + } + d := common.NewDryRunAPI() + if wikiListShouldAutoPaginate(runtime) { + d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)") + } + // When the caller passes my_library, +node-list must first resolve it + // to the real per-user space_id before listing nodes, mirroring the + // two-step orchestration used by +node-create. + if spaceID == wikiMyLibrarySpaceID { + return d. + Desc("2-step orchestration: resolve my_library -> list nodes"). + GET("/open-apis/wiki/v2/spaces/my_library"). + Desc("[1] Resolve my_library space ID"). + GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", "")). + Desc("[2] List nodes"). + Params(params). + Set("space_id", "") + } + return d. + GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))). + Params(params). + Set("space_id", spaceID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + warnIfConflictingPagingFlags(runtime) + spaceID := strings.TrimSpace(runtime.Str("space-id")) + + // Resolve the my_library alias to the per-user real space_id before + // listing, so the subsequent request hits a concrete space endpoint. + if spaceID == wikiMyLibrarySpaceID { + resolved, err := resolveMyLibrarySpaceID(runtime) + if err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved)) + spaceID = resolved + } + + nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID) + if err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Found %d node(s)\n", len(nodes)) + outData := map[string]interface{}{ + "nodes": nodes, + "has_more": hasMore, + "page_token": nextToken, + } + runtime.OutFormat(outData, &output.Meta{Count: len(nodes)}, func(w io.Writer) { + renderWikiNodesPretty(w, nodes, hasMore, nextToken) + }) + return nil + }, +} + +func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) { + pageSize := runtime.Int("page-size") + startToken := strings.TrimSpace(runtime.Str("page-token")) + parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token")) + auto := wikiListShouldAutoPaginate(runtime) + pageLimit := runtime.Int("page-limit") + + apiPath := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)) + + // Non-nil empty slice keeps json output stable as `[]` instead of `null`. + var ( + nodes = make([]map[string]interface{}, 0) + pageToken = startToken + lastHasMore bool + lastPageToken string + ) + for page := 0; ; page++ { + params := map[string]interface{}{"page_size": pageSize} + if parentNodeToken != "" { + params["parent_node_token"] = parentNodeToken + } + if pageToken != "" { + params["page_token"] = pageToken + } + data, err := runtime.CallAPI("GET", apiPath, params, nil) + if err != nil { + return nil, false, "", err + } + items, _ := data["items"].([]interface{}) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + nodes = append(nodes, wikiNodeListItem(m)) + } + } + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + if !auto { + break + } + if !lastHasMore || lastPageToken == "" { + break + } + if pageLimit > 0 && page+1 >= pageLimit { + break + } + pageToken = lastPageToken + } + return nodes, lastHasMore, lastPageToken, nil +} + +func wikiNodeListItem(m map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "space_id": common.GetString(m, "space_id"), + "node_token": common.GetString(m, "node_token"), + "obj_token": common.GetString(m, "obj_token"), + "obj_type": common.GetString(m, "obj_type"), + "parent_node_token": common.GetString(m, "parent_node_token"), + "node_type": common.GetString(m, "node_type"), + "title": common.GetString(m, "title"), + "has_child": common.GetBool(m, "has_child"), + } +} + +func renderWikiNodesPretty(w io.Writer, nodes []map[string]interface{}, hasMore bool, pageToken string) { + if len(nodes) == 0 { + if hasMore && pageToken != "" { + fmt.Fprintln(w, "Current page is empty but the server reports more pages.") + fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:") + fmt.Fprintf(w, " next page_token: %s\n", pageToken) + return + } + fmt.Fprintln(w, "No wiki nodes found.") + return + } + for i, n := range nodes { + fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(n["title"])) + fmt.Fprintf(w, " node_token: %s\n", valueOrDash(n["node_token"])) + fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(n["obj_type"])) + fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(n["obj_token"])) + hasChild, _ := n["has_child"].(bool) + fmt.Fprintf(w, " has_child: %t\n", hasChild) + if parent, _ := n["parent_node_token"].(string); parent != "" { + fmt.Fprintf(w, " parent: %s\n", parent) + } + fmt.Fprintln(w) + } + if hasMore && pageToken != "" { + fmt.Fprintf(w, "Next page token: %s\n", pageToken) + } +} diff --git a/shortcuts/wiki/wiki_space_list.go b/shortcuts/wiki/wiki_space_list.go new file mode 100644 index 000000000..7816467a6 --- /dev/null +++ b/shortcuts/wiki/wiki_space_list.go @@ -0,0 +1,211 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces" + wikiSpaceListDefaultPageSize = 50 + wikiSpaceListMaxPageSize = 50 +) + +// WikiSpaceList lists all wiki spaces the caller has access to. +var WikiSpaceList = common.Shortcut{ + Service: "wiki", + Command: "+space-list", + Description: "List wiki spaces accessible to the caller", + Risk: "read", + // Declare the narrowest valid scope: the upstream API accepts any of + // wiki:wiki / wiki:wiki:readonly / wiki:space:retrieve, but the + // framework's preflight does exact-string scope matching (see + // internal/auth/scope.go), so picking the broad readonly form would + // wrongly reject tokens that only carry the narrow retrieve scope and + // hand them a misleading missing-scope hint. + Scopes: []string{"wiki:space:retrieve"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "page-size", Type: "int", Default: strconv.Itoa(wikiSpaceListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiSpaceListMaxPageSize)}, + {Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"}, + {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"}, + }, + Tips: []string{ + "Default fetches a single page (matches other list shortcuts in this CLI); pass --page-all to pull every page.", + "The underlying API never returns the my_library personal library; resolve it via `wiki spaces get --params '{\"space_id\":\"my_library\"}'`.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateWikiListPagination(runtime, wikiSpaceListMaxPageSize) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{"page_size": runtime.Int("page-size")} + if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { + params["page_token"] = pt + } + dry := common.NewDryRunAPI() + // Auto-pagination is the default — make it explicit in the dry-run so + // callers can see whether the loop will fire. + if wikiListShouldAutoPaginate(runtime) { + dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)") + } + return dry.GET(wikiSpaceListAPIPath).Params(params) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + warnIfConflictingPagingFlags(runtime) + spaces, hasMore, nextToken, err := fetchWikiSpaces(runtime) + if err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Found %d wiki space(s)\n", len(spaces)) + outData := map[string]interface{}{ + "spaces": spaces, + "has_more": hasMore, + "page_token": nextToken, + } + runtime.OutFormat(outData, &output.Meta{Count: len(spaces)}, func(w io.Writer) { + renderWikiSpacesPretty(w, spaces, hasMore, nextToken) + }) + return nil + }, +} + +// fetchWikiSpaces honours the four pagination flags: +// - default (no --page-all, no --page-token): fetch a single page from the start +// - --page-token X: fetch a single page starting at X (auto-pagination disabled) +// - --page-all: pull subsequent pages, capped by --page-limit (default 10; 0 = unlimited) +// +// The returned slice is always non-nil so json output stays as `[]` instead of `null`. +func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{}, bool, string, error) { + pageSize := runtime.Int("page-size") + startToken := strings.TrimSpace(runtime.Str("page-token")) + auto := wikiListShouldAutoPaginate(runtime) + pageLimit := runtime.Int("page-limit") + + var ( + spaces = make([]map[string]interface{}, 0) + pageToken = startToken + lastHasMore bool + lastPageToken string + ) + for page := 0; ; page++ { + params := map[string]interface{}{"page_size": pageSize} + if pageToken != "" { + params["page_token"] = pageToken + } + data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil) + if err != nil { + return nil, false, "", err + } + items, _ := data["items"].([]interface{}) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + spaces = append(spaces, parseWikiSpaceItem(m)) + } + } + lastHasMore, _ = data["has_more"].(bool) + lastPageToken, _ = data["page_token"].(string) + if !auto { + break + } + if !lastHasMore || lastPageToken == "" { + break + } + if pageLimit > 0 && page+1 >= pageLimit { + break + } + pageToken = lastPageToken + } + return spaces, lastHasMore, lastPageToken, nil +} + +func parseWikiSpaceItem(m map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "space_id": common.GetString(m, "space_id"), + "name": common.GetString(m, "name"), + "description": common.GetString(m, "description"), + "space_type": common.GetString(m, "space_type"), + "visibility": common.GetString(m, "visibility"), + "open_sharing": common.GetString(m, "open_sharing"), + } +} + +func renderWikiSpacesPretty(w io.Writer, spaces []map[string]interface{}, hasMore bool, pageToken string) { + if len(spaces) == 0 { + // Distinguish "nothing here" from "current page empty but server says + // more pages follow" — the latter is a hint to keep paginating instead + // of giving up. + if hasMore && pageToken != "" { + fmt.Fprintln(w, "Current page is empty but the server reports more pages.") + fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:") + fmt.Fprintf(w, " next page_token: %s\n", pageToken) + return + } + fmt.Fprintln(w, "No wiki spaces found.") + return + } + for i, s := range spaces { + fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(s["name"])) + fmt.Fprintf(w, " space_id: %s\n", valueOrDash(s["space_id"])) + fmt.Fprintf(w, " space_type: %s\n", valueOrDash(s["space_type"])) + fmt.Fprintf(w, " visibility: %s\n", valueOrDash(s["visibility"])) + fmt.Fprintf(w, " open_sharing: %s\n", valueOrDash(s["open_sharing"])) + if desc, _ := s["description"].(string); desc != "" { + fmt.Fprintf(w, " description: %s\n", desc) + } + fmt.Fprintln(w) + } + if hasMore && pageToken != "" { + fmt.Fprintf(w, "Next page token: %s\n", pageToken) + } +} + +func valueOrDash(v interface{}) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return "-" +} + +// validateWikiListPagination performs flag-level validation shared by +// +space-list and +node-list. +func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error { + if n := runtime.Int("page-size"); n < 1 || n > maxPageSize { + return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize) + } + if n := runtime.Int("page-limit"); n < 0 { + return common.FlagErrorf("--page-limit must be a non-negative integer") + } + return nil +} + +// wikiListShouldAutoPaginate reports whether the fetch loop should keep +// requesting additional pages. An explicit --page-token disables auto loop +// because the caller has supplied a specific cursor. +func wikiListShouldAutoPaginate(runtime *common.RuntimeContext) bool { + if strings.TrimSpace(runtime.Str("page-token")) != "" { + return false + } + return runtime.Bool("page-all") +} + +// warnIfConflictingPagingFlags logs a notice when --page-token and --page-all +// are both set. --page-token wins (single-page fetch from the supplied cursor) +// and --page-all is silently ignored, which would otherwise look like a bug to +// callers expecting subsequent pages to be drained. +func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) { + if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") { + fmt.Fprintln(runtime.IO().ErrOut, + "warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)") + } +} diff --git a/skills/lark-doc/references/lark-doc-search.md b/skills/lark-doc/references/lark-doc-search.md index 6ca0df4d4..3047a6c2d 100644 --- a/skills/lark-doc/references/lark-doc-search.md +++ b/skills/lark-doc/references/lark-doc-search.md @@ -57,7 +57,7 @@ lark-cli docs +search \ # 按文档所有者过滤(creator_ids 传文档所有者 open_id,不是邮箱 / user_id) lark-cli docs +search \ --query "季度总结" \ - --filter '{"creator_ids":["ou_7890123456abcdef"]}' + --filter '{"creator_ids":["ou_EXAMPLE_USER_ID"]}' # 只搜索指定类型 lark-cli docs +search \ @@ -87,7 +87,7 @@ lark-cli docs +search \ # 只搜索指定分享者分享过的文档(sharer_ids 传分享者 open_id,最多 20 个) lark-cli docs +search \ --query "复盘" \ - --filter '{"sharer_ids":["ou_7890123456abcdef"]}' + --filter '{"sharer_ids":["ou_EXAMPLE_USER_ID"]}' # 按创建时间过滤并指定排序方式 lark-cli docs +search \ @@ -97,7 +97,7 @@ lark-cli docs +search \ # 组合多个筛选条件 lark-cli docs +search \ --query "项目复盘" \ - --filter '{"creator_ids":["ou_7890123456abcdef"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}' + --filter '{"creator_ids":["ou_EXAMPLE_USER_ID"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}' # 只在指定知识空间下搜 Wiki lark-cli docs +search \ @@ -179,10 +179,10 @@ lark-cli docs +search --query "方案" --format json --page-token '' ### 常见 `--filter` JSON 片段 ```json -{"creator_ids":["ou_7890123456abcdef"]} +{"creator_ids":["ou_EXAMPLE_USER_ID"]} {"doc_types":["SHEET","DOCX"]} {"chat_ids":["oc_1234567890abcdef"]} -{"sharer_ids":["ou_7890123456abcdef"]} +{"sharer_ids":["ou_EXAMPLE_USER_ID"]} {"folder_tokens":["fld_123456"]} {"only_title":true} {"only_comment":true} diff --git a/skills/lark-minutes/references/lark-minutes-search.md b/skills/lark-minutes/references/lark-minutes-search.md index 86b7e5da4..cec6099ee 100644 --- a/skills/lark-minutes/references/lark-minutes-search.md +++ b/skills/lark-minutes/references/lark-minutes-search.md @@ -174,7 +174,7 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token ' [flags]`) | [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki | | [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution | | [`+delete-space`](references/lark-wiki-delete-space.md) | Delete a wiki space, polling the async delete task when needed | +| [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller | +| [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) | +| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node | ## API Resources @@ -98,6 +101,7 @@ lark-cli wiki [flags] # 调用 API | `members.delete` | `wiki:member:update` | | `members.list` | `wiki:member:retrieve` | | `nodes.copy` | `wiki:node:copy` | +| `nodes.move` | `wiki:node:move` | | `nodes.create` | `wiki:node:create` | | `nodes.list` | `wiki:node:retrieve` | diff --git a/skills/lark-wiki/references/lark-wiki-node-copy.md b/skills/lark-wiki/references/lark-wiki-node-copy.md new file mode 100644 index 000000000..ebd3ab26a --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -0,0 +1,72 @@ +# lark-wiki +node-copy + +Copy a wiki node (including its content) to a target space or under a target parent node. Used for cross-space migration. + +> ⚠️ **High-risk write** — the upstream API is flagged `danger: true`, so this shortcut requires explicit `--yes` confirmation before issuing the request. Forgetting `--yes` returns a `confirmation_required` error and the copy is **not** performed. + +## Usage + +```bash +lark-cli wiki +node-copy \ + --space-id \ + --node-token \ + (--target-space-id | --target-parent-node-token ) \ + [--title ] \ + --yes \ + [--as user|bot] +``` + +## Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--space-id` | **Yes** | Source wiki space ID | +| `--node-token` | **Yes** | Source node token to copy | +| `--target-space-id` | Conditional | Target space ID. Required if `--target-parent-node-token` is not set | +| `--target-parent-node-token` | Conditional | Target parent node token. Required if `--target-space-id` is not set | +| `--title` | No | New title for the copied node. Omit to keep the original title | +| `--yes` | **Yes** | Confirm the high-risk operation. Without this flag the shortcut refuses to send the API request | +| `--format` | No | Output format: `json` (default) / `pretty` / `table` / `csv` / `ndjson` | +| `--as` | No | Identity: `user` or `bot` (default: `user`) | + +> At least one of `--target-space-id` or `--target-parent-node-token` must be provided. + +## Output + +```json +{ + "space_id": "target_space_id", + "node_token": "wikcn_EXAMPLE_TOKEN", + "obj_token": "doccn_EXAMPLE_TOKEN", + "obj_type": "docx", + "node_type": "origin", + "title": "Getting Started (Copy)", + "parent_node_token": "", + "has_child": false +} +``` + +## Migration workflow + +To migrate a subtree from one space to another: + +```bash +# 1. List nodes in the source space +lark-cli wiki +node-list --space-id source_space_id + +# 2. Copy each node to the target space +lark-cli wiki +node-copy \ + --space-id \ + --node-token wikcn_EXAMPLE_TOKEN \ + --target-space-id \ + --yes +``` + +## Notes + +- Copying is recursive — the subtree under the node is also copied. +- There is no native move API; migration = copy to target + (manually delete source if needed). + +## Required Scope + +`wiki:node:copy` diff --git a/skills/lark-wiki/references/lark-wiki-node-list.md b/skills/lark-wiki/references/lark-wiki-node-list.md new file mode 100644 index 000000000..ebf12c5c9 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-list.md @@ -0,0 +1,88 @@ +# lark-wiki +node-list + +List wiki nodes in a space or under a specific parent node. **Default fetches a single page** (large knowledge bases can have thousands of nodes — opt into `--page-all` explicitly with an eye on `--page-limit`). + +## Usage + +```bash +# Default: single page of root nodes +lark-cli wiki +node-list --space-id + +# Drill into a sub-directory (still single page by default) +lark-cli wiki +node-list --space-id --parent-node-token + +# Personal document library (user identity only) +lark-cli wiki +node-list --space-id my_library --as user + +# Walk every page (capped by --page-limit, default 10) +lark-cli wiki +node-list --space-id --page-all + +# Walk every page with a higher cap +lark-cli wiki +node-list --space-id --page-all --page-limit 30 + +# Resume from a cursor +lark-cli wiki +node-list --space-id --page-token + +# Pretty / table output +lark-cli wiki +node-list --space-id --format pretty +``` + +## Flags + +| Flag | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) | +| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root | +| `--page-size` | int | No | 50 | Page size, 1-50 | +| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) | +| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) | +| `--page-limit` | int | No | 10 | Max pages with `--page-all` (0 = unlimited) | +| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` | +| `--as` | enum | No | `user` | Identity: `user` or `bot` | + +## Output + +```json +{ + "ok": true, + "data": { + "nodes": [ + { + "space_id": "6946843325487912356", + "node_token": "wikcn_EXAMPLE_TOKEN", + "obj_token": "doccn_EXAMPLE_TOKEN", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Getting Started", + "has_child": true + } + ], + "has_more": false, + "page_token": "" + }, + "meta": { "count": 1 } +} +``` + +When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=` so the caller can resume via `--page-token` or by increasing `--page-limit`. + +## Traverse the wiki tree + +To list all content recursively, call `+node-list` again with each node's `node_token` as `--parent-node-token` when `has_child` is `true`. + +```bash +# Step 1: list root nodes +lark-cli wiki +node-list --space-id 6946843325487912356 + +# Step 2: drill into a node that has children +lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikcn_EXAMPLE_TOKEN +``` + +## Notes + +- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront. + +## Required Scope + +`wiki:node:retrieve` diff --git a/skills/lark-wiki/references/lark-wiki-space-list.md b/skills/lark-wiki/references/lark-wiki-space-list.md new file mode 100644 index 000000000..0662cc3b7 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-space-list.md @@ -0,0 +1,68 @@ +# lark-wiki +space-list + +List wiki spaces accessible to the caller. **Default fetches a single page** (matches the rest of the CLI's list shortcuts); pass `--page-all` to walk every page. + +## Usage + +```bash +# Default: single page (first up to --page-size items) +lark-cli wiki +space-list + +# Walk every page (capped by --page-limit, default 10) +lark-cli wiki +space-list --page-all + +# Walk every page, no cap (use with care if you have many spaces) +lark-cli wiki +space-list --page-all --page-limit 0 + +# Resume from a specific cursor (single-page fetch regardless of --page-all) +lark-cli wiki +space-list --page-token + +# Pretty / table / csv / ndjson output +lark-cli wiki +space-list --format pretty +lark-cli wiki +space-list --format table +``` + +## Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--page-size` | int | 50 | Page size, 1-50 | +| `--page-token` | string | — | Page cursor; implies single-page fetch (no auto-pagination) | +| `--page-all` | bool | `false` | Automatically paginate through all pages (capped by `--page-limit`) | +| `--page-limit` | int | 10 | Max pages with `--page-all` (0 = unlimited) | +| `--format` | enum | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` | +| `--as` | enum | `user` | Identity: `user` or `bot` | + +## Output + +```json +{ + "ok": true, + "data": { + "spaces": [ + { + "space_id": "6946843325487912356", + "name": "Engineering Wiki", + "description": "...", + "space_type": "team", + "visibility": "private", + "open_sharing": "closed" + } + ], + "has_more": false, + "page_token": "" + }, + "meta": { "count": 1 } +} +``` + +When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=` so the caller can resume via `--page-token` or by increasing `--page-limit`. + +## Notes + +- **The underlying API never returns the my_library personal library**; resolve it via `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`. +- Use `space_id` from the output as `--space-id` for `+node-list` or `+node-copy`. + +## Required Scope + +`wiki:space:retrieve` diff --git a/tests/cli_e2e/wiki/coverage.md b/tests/cli_e2e/wiki/coverage.md index 6343cf917..be022097a 100644 --- a/tests/cli_e2e/wiki/coverage.md +++ b/tests/cli_e2e/wiki/coverage.md @@ -1,13 +1,13 @@ # Wiki CLI E2E Coverage ## Metrics -- Denominator: 6 leaf commands -- Covered: 6 +- Denominator: 6 leaf commands + 3 shortcut commands +- Covered: 9 - Coverage: 100.0% ## Summary -- TestWiki_NodeWorkflow: proves the full currently-tested wiki domain surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`. -- The workflow covers both node creation/copy/listing and space lookup/listing with persisted token assertions. +- TestWiki_NodeWorkflow: proves the full currently-tested bare-API surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`. +- TestWiki_ShortcutWorkflow: covers the shortcut layer for `wiki +space-list`, `wiki +node-list`, and `wiki +node-copy` — flag→body mapping, envelope shape (`{spaces|nodes, has_more, page_token}` + `meta.count`), `--page-all` / `--page-limit` truncation, my_library alias resolution (user positive + bot validation rejection), and copy-source-survival. ## Command Table @@ -19,3 +19,6 @@ | ✓ | wiki spaces get | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get space as bot | `space_id` in `--params` | | | ✓ | wiki spaces get_node | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get created node as bot | `token`; `obj_type` in `--params` | | | ✓ | wiki spaces list | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/list spaces as bot | `page_size` in `--params` | | +| ✓ | wiki +space-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+space-list: stable envelope shape | `--page-size`; `--format json`; bot identity | | +| ✓ | wiki +node-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-list: finds child under parent; +node-list: --page-limit caps the loop and exposes cursor; +node-list --space-id my_library --as bot: validation rejection; +node-list --space-id my_library --as user: resolves and lists | `--space-id`; `--parent-node-token`; `--page-all`; `--page-size`; `--page-limit`; my_library alias | | +| ✓ | wiki +node-copy | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-copy: copies child + verifies source survives + cleanup | `--space-id`; `--node-token`; `--target-space-id`; `--title` | | diff --git a/tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go b/tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go new file mode 100644 index 000000000..863b5241f --- /dev/null +++ b/tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go @@ -0,0 +1,269 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestWiki_ShortcutWorkflow exercises the shortcut layer (wiki +space-list, +// +node-list, +node-copy) end-to-end against a real Lark tenant. The existing +// TestWiki_NodeWorkflow only hits the bare `api` command, so it does not +// protect against regressions in shortcut-specific behavior — flag → body +// mapping, envelope shape ({spaces|nodes, has_more, page_token} + meta.count), +// auto-pagination, my_library alias resolution, or required-flag validation. +func TestWiki_ShortcutWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + parentTitle := "lark-cli-e2e-wiki-sc-parent-" + suffix + childTitle := "lark-cli-e2e-wiki-sc-child-" + suffix + copyTitle := "lark-cli-e2e-wiki-sc-copy-" + suffix + + var spaceID, parentNodeToken, childNodeToken, childObjType string + + // Setup: reuse an existing first-layer node in my_library as the host so + // we never bump the top-layer node count (the bot's my_library top layer + // has hit the API's "single-layer nodes ... upper limit" — code 131003 — + // in earlier CI runs because of leftover nodes). Then create a FRESH + // intermediate parent under that host, and put the test child under the + // fresh parent. We can't put the child directly under the host because + // leftover nodes from prior runs accumulate as the host's children, so + // `+node-list --parent-node-token=` returns hundreds of unrelated + // nodes and the just-created child gets paged out (regardless of + // --page-limit) before the test can find it. An isolated intermediate + // parent always has exactly the children this test creates, so the + // pagination scan never has to dig through historical cruft. + t.Run("setup: locate my_library host node + create isolated parent + create test child", func(t *testing.T) { + listResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/my_library/nodes"}, + DefaultAs: "bot", + Params: map[string]any{"page_size": 50}, + }) + require.NoError(t, err) + listResult.AssertExitCode(t, 0) + listResult.AssertStdoutStatus(t, 0) + + items := gjson.Get(listResult.Stdout, "data.items").Array() + if len(items) == 0 { + t.Skip("skipped: my_library has no existing top-level nodes to host the test structure") + } + host := items[0] + spaceID = host.Get("space_id").String() + hostNodeToken := host.Get("node_token").String() + require.NotEmpty(t, spaceID, "host space_id must be present in listing") + require.NotEmpty(t, hostNodeToken, "host node_token must be present in listing") + + // Create a fresh intermediate parent under the host. The helper + // auto-registers a t.Cleanup callback that deletes this parent + // (and, by API cascade, anything still under it) after the test. + isolatedParent := createWikiNode(t, parentT, ctx, spaceID, map[string]any{ + "node_type": "origin", + "obj_type": "docx", + "title": parentTitle, + "parent_node_token": hostNodeToken, + }) + parentNodeToken = isolatedParent.Get("node_token").String() + require.NotEmpty(t, parentNodeToken, "isolated parent node_token must be present after create") + + // Create the test child UNDER the freshly-isolated parent. + child := createWikiNode(t, parentT, ctx, spaceID, map[string]any{ + "node_type": "origin", + "obj_type": "docx", + "title": childTitle, + "parent_node_token": parentNodeToken, + }) + childNodeToken = child.Get("node_token").String() + childObjType = child.Get("obj_type").String() + require.NotEmpty(t, childNodeToken) + }) + + // QA-P1: +space-list envelope shape is stable for JSON consumers. + // `spaces` must always be an array (never null), and pagination metadata + // fields must always exist so downstream agents can introspect. + t.Run("+space-list: stable envelope shape", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "+space-list", "--page-size", "1"}, + DefaultAs: "bot", + Format: "json", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := gjson.Parse(result.Stdout) + require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout) + assert.True(t, out.Get("data.spaces").Exists(), "data.spaces must exist") + assert.True(t, out.Get("data.spaces").IsArray(), "data.spaces must be an array, even when empty") + assert.True(t, out.Get("data.has_more").Exists(), "data.has_more must always be present") + assert.True(t, out.Get("data.page_token").Exists(), "data.page_token must always be present") + // meta.count uses `json:",omitempty"` in the envelope framework, so the + // field is dropped when the count is zero. Comparing values (gjson + // returns 0 for missing keys) keeps the assertion correct in both the + // "no spaces visible" and "some spaces" cases without requiring a + // framework-level change. + spacesLen := len(out.Get("data.spaces").Array()) + assert.Equal(t, float64(spacesLen), out.Get("meta.count").Float(), + "meta.count must equal len(data.spaces) (or be omitted when zero); stdout:\n%s", result.Stdout) + }) + + // QA-P1: +node-list correctly maps flags onto the underlying request body + // and surfaces the child we just created under the parent. + t.Run("+node-list: finds child under parent", func(t *testing.T) { + require.NotEmpty(t, spaceID) + require.NotEmpty(t, parentNodeToken) + require.NotEmpty(t, childNodeToken) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "wiki", "+node-list", + "--space-id", spaceID, + "--parent-node-token", parentNodeToken, + "--page-all", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := gjson.Parse(result.Stdout) + require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout) + + match := out.Get(`data.nodes.#(node_token=="` + childNodeToken + `")`) + require.True(t, match.Exists(), "+node-list did not return the child we created:\n%s", result.Stdout) + assert.Equal(t, childTitle, match.Get("title").String()) + assert.Equal(t, parentNodeToken, match.Get("parent_node_token").String()) + }) + + // QA-P2: --page-size 1 --page-all --page-limit 1 must aggregate exactly + // one page and surface the next cursor when has_more=true. This catches + // regressions where the pagination loop overruns the cap or fails to + // surface has_more / page_token. + t.Run("+node-list: --page-limit caps the loop and exposes cursor", func(t *testing.T) { + require.NotEmpty(t, spaceID) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "wiki", "+node-list", + "--space-id", spaceID, + "--page-size", "1", + "--page-all", + "--page-limit", "1", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := gjson.Parse(result.Stdout) + require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout) + nodes := out.Get("data.nodes").Array() + assert.LessOrEqual(t, len(nodes), 1, "--page-limit=1 + --page-size=1 should yield ≤1 node, got %d", len(nodes)) + // has_more / page_token must still exist — never elided — so + // callers can resume regardless of whether the cap actually fired. + assert.True(t, out.Get("data.has_more").Exists()) + assert.True(t, out.Get("data.page_token").Exists()) + }) + + // QA-P1: +node-copy creates a copy under the same space and the source + // stays put (copy ≠ move). Cleanup deletes the copy. The copy is placed + // under the same host parent we use for the test child, so it doesn't + // add another top-layer node and trip the per-space limit. + t.Run("+node-copy: copies child + verifies source survives + cleanup", func(t *testing.T) { + require.NotEmpty(t, spaceID) + require.NotEmpty(t, parentNodeToken) + require.NotEmpty(t, childNodeToken) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "wiki", "+node-copy", + "--space-id", spaceID, + "--node-token", childNodeToken, + "--target-parent-node-token", parentNodeToken, + "--title", copyTitle, + }, + // +node-copy is now declared high-risk-write to align with the + // upstream API's `danger: true` flag, so the framework requires + // explicit confirmation before issuing the request. + Yes: true, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := gjson.Parse(result.Stdout) + require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout) + copiedNodeToken := out.Get("data.node_token").String() + copiedSpaceID := out.Get("data.space_id").String() + copiedObjType := out.Get("data.obj_type").String() + require.NotEmpty(t, copiedNodeToken, "stdout:\n%s", result.Stdout) + require.NotEmpty(t, copiedSpaceID) + assert.Equal(t, copyTitle, out.Get("data.title").String()) + + parentT.Cleanup(func() { + cleanupCtx, cancel := clie2e.CleanupContext() + defer cancel() + deleteResult, deleteErr := deleteWikiNode(cleanupCtx, copiedSpaceID, copiedNodeToken, copiedObjType) + clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr) + }) + + // Copy must be retrievable; source must still exist (copy ≠ move). + copied := getWikiNode(t, ctx, copiedNodeToken) + assert.Equal(t, copyTitle, copied.Get("title").String()) + original := getWikiNode(t, ctx, childNodeToken) + assert.Equal(t, childTitle, original.Get("title").String(), + "source node must remain after +node-copy (copy is non-destructive)") + _ = childObjType // reserved for future +node-list filter checks + }) + + // QA-P2: bot identity must be rejected upfront when --space-id=my_library + // because the personal-library alias is per-user and meaningless for a + // tenant_access_token. The shortcut layer should fail before sending any + // HTTP request, with a validation error mentioning my_library. + t.Run("+node-list --space-id my_library --as bot: validation rejection", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "+node-list", "--space-id", "my_library"}, + DefaultAs: "bot", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "bot + my_library must fail") + + combined := strings.ToLower(result.Stdout + "\n" + result.Stderr) + assert.Contains(t, combined, "my_library", + "error must mention my_library to disambiguate from generic auth failures; got stdout=%s stderr=%s", + result.Stdout, result.Stderr) + }) + + // QA-P2: user identity must positively resolve --space-id=my_library to a + // real per-user space_id and proceed to list nodes. Skipped when no user + // token is available (matches the rest of the suite's user-flow gating). + t.Run("+node-list --space-id my_library --as user: resolves and lists", func(t *testing.T) { + clie2e.SkipWithoutUserToken(t) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"wiki", "+node-list", "--space-id", "my_library", "--page-size", "1"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := gjson.Parse(result.Stdout) + require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout) + assert.True(t, out.Get("data.nodes").Exists(), "data.nodes must exist after my_library resolution") + assert.True(t, out.Get("data.nodes").IsArray(), "data.nodes must be an array") + // stderr must record the my_library resolution so users/agents can + // see what space_id the alias mapped to. + assert.Contains(t, result.Stderr, "Resolved my_library", + "expected my_library resolution log on stderr; got: %s", result.Stderr) + }) +} From 4a45e001394535391876101c07188ca0a4acf837 Mon Sep 17 00:00:00 2001 From: SunPeiYang996 Date: Fri, 15 May 2026 16:18:49 +0800 Subject: [PATCH 02/61] docs: add svg whiteboard support to doc v2 skill (#901) Change-Id: Icada6fb894aaf9a00187fa68c132d3ade8223b99 --- skills/lark-doc/SKILL.md | 5 +- skills/lark-doc/references/lark-doc-update.md | 2 +- .../references/lark-doc-whiteboard.md | 70 ++++++++++++++----- skills/lark-doc/references/lark-doc-xml.md | 6 +- .../style/lark-doc-create-workflow.md | 18 +++-- .../references/style/lark-doc-style.md | 57 ++++++++------- .../style/lark-doc-update-workflow.md | 22 +++--- 7 files changed, 118 insertions(+), 62 deletions(-) diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 33b30dcc5..46b249fdf 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -1,7 +1,6 @@ --- name: lark-doc -version: 2.0.0 -description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、画板、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。" +description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。" metadata: requires: bins: ["lark-cli"] @@ -34,6 +33,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen ## 快速决策 - 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节 +- 写文档时,重要信息(核心流程、架构、对比、风险、路线图、关键指标、因果关系)优先规划为画板,不要只用文字或表格承载 +- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `完整 SVG`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 ``,再启动 SubAgent 读取 `lark-whiteboard` 写入 - 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview` - 用户明确说"下载素材" → 用 `lark-cli docs +media-download` - 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`) diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md index c0e5521cf..7eeb13a9d 100644 --- a/skills/lark-doc/references/lark-doc-update.md +++ b/skills/lark-doc/references/lark-doc-update.md @@ -221,7 +221,7 @@ lark-cli docs +update --api-version v2 --doc "" --command str_replace \ ## 画板处理 -> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 ``,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。 +> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 ``,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。 画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。 diff --git a/skills/lark-doc/references/lark-doc-whiteboard.md b/skills/lark-doc/references/lark-doc-whiteboard.md index e4b635ae4..c6f9b04b2 100644 --- a/skills/lark-doc/references/lark-doc-whiteboard.md +++ b/skills/lark-doc/references/lark-doc-whiteboard.md @@ -6,46 +6,79 @@ | Skill | 核心职责 | 约束 | |------|------|------| -| `lark-doc` | 文档内容读取/更新、插入空白画板占位、获取 board_token | 不能直接编辑画板内容;`docs +update` 的画板能力仅限插入空白占位 | -| `lark-whiteboard` | 查询/导出画板(+query);图表内容生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入画板(+update) | 图表内容生成由此 skill 完整执行,不依赖外部调度 | +| `lark-doc` | 识别画板机会、判断简单/复杂、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容;简单图不需要读取 `lark-whiteboard` | +| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅复杂图或已有画板更新时由独立 SubAgent 读取 | + +## 画板优先规则 + +写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。 + +同一篇文档可以有多个画板。优先多个聚焦画板,而不是把所有信息塞进一张大图。 ## 文档与画板协同流程 -### 步骤 1:判断场景 +### 步骤 1:识别画板机会 | 场景 | 入口 | |------|------| -| 文档中需要插入新画板 | 继续步骤 2 | -| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3 | +| 文档中需要插入简单新画板 | 走步骤 2A | +| 文档中需要插入复杂新画板 | 走步骤 2B | +| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B | | 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 | -### 步骤 2:在文档中创建空白画板 +简单图判定:节点少、静态、布局可控、适合一个完整自包含 SVG 表达,例如小型流程、2-3 方对比、小型状态机、简单时间线或小型示意图。 + +复杂图判定:节点多、跨泳道/跨系统、需要自动布局或精细排版、包含数据图表、组织架构、复杂架构、复杂依赖、已有画板更新,或需要 `lark-whiteboard` 的渲染验证。 + +### 步骤 2A:简单图 — SubAgent 直接插入 SVG 画板 + +主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入: -- 创建场景:`docs +create`;编辑场景:`docs +update` -- markdown 中使用 ``(不要转义) -- 多个画板时,在相应的地方插入各自的 whiteboard 标签 -- 从响应的 `data.board_tokens` 中读取 token 列表 +```xml +... +``` -### 步骤 3:生成并写入画板内容 +简单图 SubAgent 的最小上下文: +- doc token、插入位置(标题 / block_id / command) +- 图表目标、受众、源段落或数据 +- 要求读取 `lark-doc-xml.md`;不需要读取 `lark-whiteboard` +- SVG 必须完整自包含:包含 `` 根节点和 `viewBox`,不引用外部图片、脚本、远程资源 -读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),跳至"渲染 & 写入画板"章节,按其完整流程为每个 board_token 生成并写入图表内容。 +### 步骤 2B:复杂图 — 先创建空白画板 -多个画板时依次处理,每个画板完成后再处理下一个。 +- 主 Agent 使用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 ``。 +- 从 v2 响应的 `data.document.new_blocks[]` 中读取 `block_type == "whiteboard"` 的 `block_token` 作为 board_token。 + +### 步骤 3B:复杂图或已有画板 — 启动 lark-whiteboard SubAgent + +复杂图和已有画板更新必须启动 SubAgent。主 Agent 只传最小上下文,不直接执行 `lark-whiteboard` 的渲染和写入流程。 + +复杂图 SubAgent 的最小上下文: +- board_token +- 图表目标、推荐画板类型、受众 +- 与图表直接相关的源段落或数据 +- 要求读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),按其完整流程写入该 board_token + +多个画板互不依赖时,可并行启动多个 SubAgent;每个 SubAgent 只负责一个画板或一个 SVG 插入点,不要互相复用上下文。 ### 步骤 4:完成校验 -- 确认每个 token 对应的画板都已填充真实内容 -- 不保留空白占位画板;只有空白画板而无内容视为任务未完成 +- 简单 SVG:确认插入的是 ``,且内容是完整 `...` +- 复杂画板:确认每个 token 对应的画板都已填充真实内容 +- 不保留空白占位画板;复杂路径只有空白画板而无内容视为任务未完成 --- ## 语义与画板类型映射 +下表用于帮助主 Agent 判断简单/复杂路径,并给 SubAgent 指定推荐画板类型。 + | 语义 | 画板类型 | |------|------| -| 架构/分层/技术方案/模块依赖/调用关系 | 架构图 | -| 流程/审批/部署/业务流转/状态机 | 流程图 | -| 跨角色流程/跨系统交互/端到端链路 | 泳道图 | +| 小型流程/状态机/简单时间线/小型对比/小型示意图 | SVG 画板(简单路径) | +| 架构/分层/技术方案/模块依赖/调用关系 | 架构图(复杂路径) | +| 流程/审批/部署/业务流转/状态机 | 流程图(按复杂度分流) | +| 跨角色流程/跨系统交互/端到端链路 | 泳道图(复杂路径) | | 组织/层级/汇报关系 | 组织架构图 | | 时间线/里程碑/版本规划 | 里程碑图 | | 因果/复盘/根因分析 | 鱼骨图 | @@ -56,6 +89,7 @@ | 转化漏斗/销售漏斗 | 漏斗图 | | 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid | | 数据分布/占比/饼图 | Mermaid | +| 简单自定义图形/小型 SVG 示意图 | SVG 画板(简单路径) | | 柱状图/条形图/数据对比 | 柱状图 | | 折线图/趋势图/时序数据 | 折线图 | diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md index 9dff841dd..a6d1716be 100644 --- a/skills/lark-doc/references/lark-doc-xml.md +++ b/skills/lark-doc/references/lark-doc-xml.md @@ -15,7 +15,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr |-|-|-| | `` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` | | `` + `` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` | -| `` | 嵌入画板 | `type`: `mermaid` \| `plantuml` \| `blank` | +| `` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` | | `
` | (代码块,内含 `code`)| `lang`, `caption` |
 | `
` | 视图容器 | `view-type` | | `` | 书签链接 | ``,必传 name 和 href | @@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr 文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建: - `` — `` 上传网络图片 -- `` — `` 空白;`内容` 带内容; +- `` — 简单图由 SubAgent 直接插入 `完整自包含 SVG`;复杂图使用 `` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入; - `` — `` 空白;`` 复制已有 - `` — ``,必传 task-id(任务 guid) - `` — ``,必传 chat-id @@ -166,4 +166,4 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr -``` \ No newline at end of file +``` diff --git a/skills/lark-doc/references/style/lark-doc-create-workflow.md b/skills/lark-doc/references/style/lark-doc-create-workflow.md index 965d98511..c4bda2416 100644 --- a/skills/lark-doc/references/style/lark-doc-create-workflow.md +++ b/skills/lark-doc/references/style/lark-doc-create-workflow.md @@ -19,7 +19,7 @@ ### 第一波 — 规划与骨架(串行) 1. 分析用户需求:受众、目的、范围 -2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block +2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block;承载重要信息的章节优先规划画板 3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `` + 各级标题 + 每节一句占位摘要 - ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。 - 完整内容留到第二波,由各 Agent 用 `docs +update --command append` 或 `block_insert_after` 分段写入。 @@ -35,11 +35,13 @@ 5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果 6. 评估样式达标(富 block 密度、元素多样性、连续 `

` 数量) -7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节及推荐的画板类型 +7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、简单/复杂路径和用于画图的源内容 -### 第四波 — 润色与图表(并行 Agent) -8. Spawn Agent 定向改进:(结合 `lark-doc-style.md` 润色) - - **优先处理第三波识别出的画板需求**:简单图直接 ``,复杂图 spawn Agent 使用 **lark-whiteboard** skill +### 第四波 — 画板与润色(并行 Agent) +8. **优先处理第三波识别出的画板需求**: + - 简单图:启动 SVG SubAgent,直接插入 `完整 SVG`;不读取 **lark-whiteboard** + - 复杂图:主 Agent 先插入 `` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板 +9. Spawn 内容改写 Agent 定向润色: - 文字密集章节转为 ``/``/`` - 主要章节间补充 `
` - 本地图片使用 `docs +media-insert` 插入 @@ -47,4 +49,8 @@ ## Agent 子任务要求 -Spawn Agent 时必须提供:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。 +内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。 + +SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `...`,不改其他正文,也不读取 `lark-whiteboard`。 + +复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。 diff --git a/skills/lark-doc/references/style/lark-doc-style.md b/skills/lark-doc/references/style/lark-doc-style.md index 321dfa215..0a27a173e 100644 --- a/skills/lark-doc/references/style/lark-doc-style.md +++ b/skills/lark-doc/references/style/lark-doc-style.md @@ -8,26 +8,29 @@ 2. **Front-load 结论**:文档以 `` 开头概括核心结论;每章节首段点明要旨 3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `
` 分隔 4. **最少惊讶**:同类信息使用同类元素,全篇风格统一 +5. **重要信息画板化**:核心流程、架构、对比、风险、路线图、指标趋势等重要信息优先使用画板表达 ## 二、元素选择指南 -涉及图表需求时,简单图用 `` 内嵌,复杂图使用 **lark-whiteboard** skill。 - -| 场景 | 推荐方案 | -|-|-| -| 核心结论 / 摘要 / 注意事项 | `` + emoji + 背景色 | -| 方案对比 / 优劣势 / Before vs After | `` 2 列分栏 | -| 3+ 属性的结构化数据 / 指标表 | `
` + 表头背景色 | -| 任务清单 / 检查项 | `` | -| 代码片段 | `
` |
-| 引用 / 公式 | `
` / `` | -| 操作入口 / 跳转链接 | `
` + 表头背景色 | +| 任务清单 / 检查项 | `` | +| 代码片段 | `
`                                 |
+| 引用 / 公式 | `
` / `` | +| 操作入口 / 跳转链接 | `
` / `` / `` 承载 ### 画板语法与插入 -> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。 +> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需启动 SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。 + +#### 简单 SVG 画板(SubAgent 插入) + +1. 主 Agent 启动 SubAgent,传入 doc token、插入位置、图表目标和源内容 +2. SubAgent 使用 `完整自包含 SVG` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 +3. SVG 必须包含 `` 根节点和 `viewBox`,不要引用外部图片、脚本或远程资源 -#### 内嵌 Mermaid / PlantUML(首选) -简单图直接用 `语法`,作为 block 嵌入文档。 +#### 复杂画板(空白画板 + lark-whiteboard SubAgent) -#### DSL 画板(Mermaid / PlantUML 不够用时) -需要架构图、对比图、组织架构等复杂结构时: -1. 用 `` 通过 `docs +create` / `docs +update` 插入空白画板 -2. 从响应 `data.document.new_blocks` 中提取画板 `block_token` -3. 切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 设计并上传 DSL +1. 用 `` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入空白画板 +2. 从 v2 响应 `data.document.new_blocks` 中提取画板 `block_token` +3. 必须启动 SubAgent,把 `block_token`、图表目标、推荐画板类型和源内容交给它 +4. SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 并写入该画板;主 Agent 不直接调用画板渲染流程 更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。 diff --git a/skills/lark-doc/references/style/lark-doc-update-workflow.md b/skills/lark-doc/references/style/lark-doc-update-workflow.md index 2ea3c6522..20dd03fa9 100644 --- a/skills/lark-doc/references/style/lark-doc-update-workflow.md +++ b/skills/lark-doc/references/style/lark-doc-update-workflow.md @@ -25,24 +25,30 @@ - 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids` - 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope" 2. 系统性评估:结构清晰度、富 block 密度(≥40%)、元素多样性(≥3种)、连续 `

` 是否超过 3 段、是否有开头 callout 和章节 `


` -3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。记录需要插图的章节(block ID)及推荐的画板类型 +3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、简单/复杂路径和源内容片段 4. 向用户简要说明改进计划(包含识别出的画板机会) ### 第二波 — 定向改写(并行 Agent) -5. Spawn Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:(见 `lark-doc-style.md`) +5. **优先处理第一波识别出的画板候选段落**: + - 简单图:启动 SVG SubAgent,直接插入 `完整 SVG`;不读取 **lark-whiteboard** + - 复杂图:主 Agent 先插入 `` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板 +6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:(见 `lark-doc-style.md`) - 开头适当添加 ``、重组引言 - - 纯文本转为 ``/`
`/`` - - **对第一波识别出的画板候选段落**:简单图直接 ``,复杂图 spawn Agent 使用 **lark-whiteboard** skill - - 添加流程图、对比分栏等富 block + - 纯文本转为 ``/`
`/`` + - 添加低重要度对比分栏、关键提示等富 block;画板类需求只走第 5 步 ### 第三波 — 验证(串行) -5. 获取更新后文档局部内容,重新检查样式指标 -6. 未达标则定向修正,向用户呈现结果 +7. 获取更新后文档局部内容,重新检查样式指标 +8. 未达标则定向修正,向用户呈现结果 ## Agent 子任务要求 -Spawn Agent 时必须提供:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。 +内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。 + +SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `...`,不改其他正文,也不读取 `lark-whiteboard`。 + +复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。 **上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。 From 7400226e3427203a1fd1f8c454210005c2ec8255 Mon Sep 17 00:00:00 2001 From: songyoung77 Date: Fri, 15 May 2026 18:28:56 +0800 Subject: [PATCH 03/61] feat(doc): add --width/--height flags to docs +media-insert (#832) * feat(doc): add width/height params to buildBatchUpdateData Extend buildBatchUpdateData signature with width and height int params. When mediaType is "image" and either dimension is positive, the value is included in the replace_image payload. Existing call sites pass 0, 0. * feat(doc): add --width/--height flags with validation to docs +media-insert * feat(doc): add aspect-ratio auto-calculation helpers Add computeMissingDimension (pure ratio math) and detectImageDimensions (header-only image.DecodeConfig) with PNG/JPEG/GIF blank-import decoders, plus imageDimensions struct; drive with two new TDD tests. * feat(doc): wire --width/--height into Execute with aspect-ratio calculation * feat(doc): add best-effort dimension computation to DryRun * docs: add --width/--height to docs +media-insert SKILL.md * fix: add SafeInputPath validation to detectImageDimensionsFromPath * fix: guard computeMissingDimension against division by zero and add rounding * fix: add dimension upper bound, fix err variable reuse in Execute * refactor: use early-return guard for zero native dimensions per review * fix: add pixels unit to dimension validation error messages * fix: surface dimension detection failures in dry-run to match Execute behavior * fix: move dimension detection before upload to fail fast * fix: restore withRollbackWarning on dimension detection errors in Execute Dimension detection runs after the placeholder block is created (Step 2), so failures must clean up the block to avoid leaving an empty placeholder in the document. --- shortcuts/doc/doc_media_insert.go | 149 ++++++++++- shortcuts/doc/doc_media_insert_test.go | 247 +++++++++++++++++- .../references/lark-doc-media-insert.md | 8 + 3 files changed, 393 insertions(+), 11 deletions(-) diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go index cd34db569..5c31495a5 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -7,6 +7,11 @@ import ( "bytes" "context" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" "path/filepath" "strings" @@ -55,6 +60,8 @@ var DocMediaInsert = common.Shortcut{ {Name: "selection-with-ellipsis", Desc: "plain text (or 'start...end' to disambiguate) matching the target block's content. Media is inserted at the top-level ancestor of the matched block — i.e., when the selection is inside a callout, table cell, or nested list, media lands outside that container, not inside it. Pass 'start...end' (a unique prefix and suffix separated by '...') when the plain text appears in more than one block"}, {Name: "before", Type: "bool", Desc: "insert before the matched block instead of after (requires --selection-with-ellipsis)"}, {Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"}, + {Name: "width", Type: "int", Desc: "image display width in pixels (only for --type=image); if --height is omitted it is auto-computed from the source image aspect ratio"}, + {Name: "height", Type: "int", Desc: "image display height in pixels (only for --type=image); if --width is omitted it is auto-computed from the source image aspect ratio"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { filePath := runtime.Str("file") @@ -93,6 +100,24 @@ var DocMediaInsert = common.Shortcut{ return output.ErrValidation("--file-view only applies when --type=file") } } + widthChanged := runtime.Changed("width") + heightChanged := runtime.Changed("height") + if (widthChanged || heightChanged) && runtime.Str("type") != "image" { + return output.ErrValidation("--width/--height only apply when --type=image") + } + if widthChanged && runtime.Int("width") <= 0 { + return output.ErrValidation("--width must be a positive integer") + } + if heightChanged && runtime.Int("height") <= 0 { + return output.ErrValidation("--height must be a positive integer") + } + const maxDimension = 10000 + if widthChanged && runtime.Int("width") > maxDimension { + return output.ErrValidation("--width must not exceed %d pixels", maxDimension) + } + if heightChanged && runtime.Int("height") > maxDimension { + return output.ErrValidation("--height must not exceed %d pixels", maxDimension) + } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -120,7 +145,25 @@ var DocMediaInsert = common.Shortcut{ } else { createBlockData["index"] = "" } - batchUpdateData := buildBatchUpdateData("", mediaType, "", runtime.Str("align"), caption) + // Best-effort dimension computation for dry-run. + dryWidth := runtime.Int("width") + dryHeight := runtime.Int("height") + widthChanged := runtime.Changed("width") + heightChanged := runtime.Changed("height") + + if (widthChanged || heightChanged) && !(widthChanged && heightChanged) { + if filePath == "" { + fmt.Fprintf(runtime.IO().ErrOut, "Note: cannot detect clipboard image dimensions in dry-run; provide both --width and --height for accurate preview\n") + } else if nativeW, nativeH, err := detectImageDimensionsFromPath(runtime.FileIO(), filePath); err == nil { + dims := computeMissingDimension(dryWidth, dryHeight, nativeW, nativeH) + dryWidth = dims.width + dryHeight = dims.height + } else { + fmt.Fprintf(runtime.IO().ErrOut, "Note: unable to detect image dimensions from %s; provide both --width and --height to avoid failure at execution time\n", filePath) + } + } + + batchUpdateData := buildBatchUpdateData("", mediaType, "", runtime.Str("align"), caption, dryWidth, dryHeight) d := common.NewDryRunAPI() totalSteps := 4 @@ -188,6 +231,9 @@ var DocMediaInsert = common.Shortcut{ if runtime.Bool("from-clipboard") { d.Set("upload_size_note", "clipboard size unknown; single-part vs multipart decision deferred to runtime") } + if runtime.Bool("from-clipboard") && (widthChanged || heightChanged) && !(widthChanged && heightChanged) { + d.Set("dimension_note", "clipboard dimensions unknown; aspect-ratio calculation deferred to runtime") + } return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -314,6 +360,42 @@ var DocMediaInsert = common.Shortcut{ // interface stays a true nil for the --file path. Passing a typed-nil // *bytes.Reader here would make the downstream `if cfg.Content != nil` // check incorrectly take the clipboard branch and crash on Read. + // Resolve display dimensions before upload to fail fast on unreadable images. + var finalWidth, finalHeight int + if mediaType == "image" { + userWidth := runtime.Int("width") + userHeight := runtime.Int("height") + widthChanged := runtime.Changed("width") + heightChanged := runtime.Changed("height") + + if widthChanged && heightChanged { + finalWidth = userWidth + finalHeight = userHeight + } else if widthChanged || heightChanged { + var nativeW, nativeH int + var dimErr error + if clipboardContent != nil { + nativeW, nativeH, dimErr = detectImageDimensions(bytes.NewReader(clipboardContent)) + } else { + f, openErr := runtime.FileIO().Open(filePath) + if openErr != nil { + return withRollbackWarning(output.ErrValidation( + "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName)) + } + nativeW, nativeH, dimErr = detectImageDimensions(f) + f.Close() + } + if dimErr != nil { + return withRollbackWarning(output.ErrValidation( + "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName)) + } + dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH) + finalWidth = dims.width + finalHeight = dims.height + fmt.Fprintf(runtime.IO().ErrOut, "Image dimensions: %dx%d (native: %dx%d)\n", finalWidth, finalHeight, nativeW, nativeH) + } + } + uploadCfg := UploadDocMediaFileConfig{ FilePath: filePath, FileName: fileName, @@ -337,16 +419,23 @@ var DocMediaInsert = common.Shortcut{ if _, err := runtime.CallAPI("PATCH", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)), - nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption)); err != nil { + nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil { return withRollbackWarning(err) } - runtime.Out(map[string]interface{}{ + outData := map[string]interface{}{ "document_id": documentID, "block_id": blockId, "file_token": fileToken, "type": mediaType, - }, nil) + } + if finalWidth > 0 { + outData["width"] = finalWidth + } + if finalHeight > 0 { + outData["height"] = finalHeight + } + runtime.Out(outData, nil) return nil }, } @@ -453,7 +542,51 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string } } -func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string) map[string]interface{} { +type imageDimensions struct { + width int + height int +} + +func computeMissingDimension(userWidth, userHeight, nativeWidth, nativeHeight int) imageDimensions { + if nativeWidth <= 0 || nativeHeight <= 0 { + return imageDimensions{width: userWidth, height: userHeight} + } + if userWidth > 0 && userHeight == 0 { + return imageDimensions{ + width: userWidth, + height: (userWidth*nativeHeight + nativeWidth/2) / nativeWidth, + } + } + if userHeight > 0 && userWidth == 0 { + return imageDimensions{ + width: (userHeight*nativeWidth + nativeHeight/2) / nativeHeight, + height: userHeight, + } + } + return imageDimensions{width: userWidth, height: userHeight} +} + +func detectImageDimensions(r io.Reader) (width, height int, err error) { + cfg, _, err := image.DecodeConfig(r) + if err != nil { + return 0, 0, err + } + return cfg.Width, cfg.Height, nil +} + +func detectImageDimensionsFromPath(fio fileio.FileIO, filePath string) (int, int, error) { + if _, err := validate.SafeInputPath(filePath); err != nil { + return 0, 0, err + } + f, err := fio.Open(filePath) + if err != nil { + return 0, 0, err + } + defer f.Close() + return detectImageDimensions(f) +} + +func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string, width, height int) map[string]interface{} { request := map[string]interface{}{ "block_id": blockID, } @@ -465,6 +598,12 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin replaceImage := map[string]interface{}{ "token": fileToken, } + if width > 0 { + replaceImage["width"] = width + } + if height > 0 { + replaceImage["height"] = height + } if alignVal, ok := alignMap[alignStr]; ok { replaceImage["align"] = alignVal } diff --git a/shortcuts/doc/doc_media_insert_test.go b/shortcuts/doc/doc_media_insert_test.go index 71d211f75..19574423b 100644 --- a/shortcuts/doc/doc_media_insert_test.go +++ b/shortcuts/doc/doc_media_insert_test.go @@ -6,6 +6,7 @@ package doc import ( "context" "encoding/json" + "fmt" "reflect" "strings" "testing" @@ -176,7 +177,7 @@ func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) { func TestBuildBatchUpdateDataForImage(t *testing.T) { t.Parallel() - got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text") + got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 0, 0) want := map[string]interface{}{ "requests": []interface{}{ map[string]interface{}{ @@ -199,7 +200,7 @@ func TestBuildBatchUpdateDataForImage(t *testing.T) { func TestBuildBatchUpdateDataForFile(t *testing.T) { t.Parallel() - got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "") + got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 0, 0) want := map[string]interface{}{ "requests": []interface{}{ map[string]interface{}{ @@ -215,6 +216,48 @@ func TestBuildBatchUpdateDataForFile(t *testing.T) { } } +func TestBuildBatchUpdateDataForImageWithWidthHeight(t *testing.T) { + t.Parallel() + + got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 800, 447) + want := map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "block_id": "blk_1", + "replace_image": map[string]interface{}{ + "token": "file_tok", + "width": 800, + "height": 447, + "align": 2, + "caption": map[string]interface{}{"content": "caption text"}, + }, + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildBatchUpdateData(image, 800, 447) = %#v, want %#v", got, want) + } +} + +func TestBuildBatchUpdateDataForFileIgnoresWidthHeight(t *testing.T) { + t.Parallel() + + got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 800, 600) + want := map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "block_id": "blk_2", + "replace_file": map[string]interface{}{ + "token": "file_tok", + }, + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildBatchUpdateData(file, 800, 600) = %#v, want %#v", got, want) + } +} + func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) { t.Parallel() @@ -669,10 +712,202 @@ func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string return common.TestNewRuntimeContext(cmd, nil) } -// Validate is the real user-facing contract for --file-view: unknown -// values must be rejected, and passing the flag alongside --type!=file -// must also be rejected. buildCreateBlockData tests alone cannot catch -// regressions here, so lock the guard logic down explicitly. +func newMediaInsertValidateRuntimeWithSize(t *testing.T, doc, mediaType string, width, height int, setWidth, setHeight bool) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "docs +media-insert"} + cmd.Flags().String("file", "", "") + cmd.Flags().Bool("from-clipboard", false, "") + cmd.Flags().String("doc", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("file-view", "", "") + cmd.Flags().Int("width", 0, "") + cmd.Flags().Int("height", 0, "") + cmd.Flags().String("selection-with-ellipsis", "", "") + cmd.Flags().Bool("before", false, "") + if err := cmd.Flags().Set("file", "dummy.bin"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("doc", doc); err != nil { + t.Fatalf("set --doc: %v", err) + } + if err := cmd.Flags().Set("type", mediaType); err != nil { + t.Fatalf("set --type: %v", err) + } + if setWidth { + if err := cmd.Flags().Set("width", fmt.Sprintf("%d", width)); err != nil { + t.Fatalf("set --width: %v", err) + } + } + if setHeight { + if err := cmd.Flags().Set("height", fmt.Sprintf("%d", height)); err != nil { + t.Fatalf("set --height: %v", err) + } + } + return common.TestNewRuntimeContext(cmd, nil) +} + +func TestDocMediaInsertValidateWidthHeightOnlyForImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mediaType string + width int + height int + setWidth bool + setHeight bool + wantErr string + }{ + { + name: "width with file type is rejected", + mediaType: "file", + width: 800, + setWidth: true, + wantErr: "--width/--height only apply when --type=image", + }, + { + name: "height with file type is rejected", + mediaType: "file", + height: 600, + setHeight: true, + wantErr: "--width/--height only apply when --type=image", + }, + { + name: "explicit zero width is rejected", + mediaType: "image", + width: 0, + setWidth: true, + wantErr: "--width must be a positive integer", + }, + { + name: "negative width is rejected", + mediaType: "image", + width: -1, + setWidth: true, + wantErr: "--width must be a positive integer", + }, + { + name: "negative height is rejected", + mediaType: "image", + height: -5, + setHeight: true, + wantErr: "--height must be a positive integer", + }, + { + name: "valid width with image type is accepted", + mediaType: "image", + width: 800, + setWidth: true, + }, + { + name: "valid width and height with image type is accepted", + mediaType: "image", + width: 800, + height: 600, + setWidth: true, + setHeight: true, + }, + } + + for _, ttTemp := range tests { + tt := ttTemp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnValidateSize", tt.mediaType, tt.width, tt.height, tt.setWidth, tt.setHeight) + err := DocMediaInsert.Validate(context.Background(), rt) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestDocMediaInsertValidateNoWidthHeightIsValid(t *testing.T) { + t.Parallel() + + rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnNoSize", "image", 0, 0, false, false) + err := DocMediaInsert.Validate(context.Background(), rt) + if err != nil { + t.Fatalf("Validate() unexpected error when neither --width nor --height passed: %v", err) + } +} + +func TestAutoAspectRatioFromWidth(t *testing.T) { + t.Parallel() + + // Native image: 1200x800 (3:2 ratio) + // User provides width=600 → expected height = 600 * 800 / 1200 = 400 + got := computeMissingDimension(600, 0, 1200, 800) + wantWidth, wantHeight := 600, 400 + if got.width != wantWidth || got.height != wantHeight { + t.Fatalf("computeMissingDimension(600, 0, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight) + } +} + +func TestAutoAspectRatioFromHeight(t *testing.T) { + t.Parallel() + + // Native image: 1200x800 (3:2 ratio) + // User provides height=400 → expected width = 400 * 1200 / 800 = 600 + got := computeMissingDimension(0, 400, 1200, 800) + wantWidth, wantHeight := 600, 400 + if got.width != wantWidth || got.height != wantHeight { + t.Fatalf("computeMissingDimension(0, 400, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight) + } +} + +func TestComputeMissingDimensionBothProvided(t *testing.T) { + t.Parallel() + got := computeMissingDimension(800, 600, 1200, 900) + if got.width != 800 || got.height != 600 { + t.Fatalf("computeMissingDimension(800, 600, 1200, 900) = (%d, %d), want (800, 600)", got.width, got.height) + } +} + +func TestComputeMissingDimensionNeitherProvided(t *testing.T) { + t.Parallel() + got := computeMissingDimension(0, 0, 1200, 900) + if got.width != 0 || got.height != 0 { + t.Fatalf("computeMissingDimension(0, 0, 1200, 900) = (%d, %d), want (0, 0)", got.width, got.height) + } +} + +func TestComputeMissingDimensionZeroNativeWidth(t *testing.T) { + t.Parallel() + got := computeMissingDimension(600, 0, 0, 800) + if got.width != 600 || got.height != 0 { + t.Fatalf("computeMissingDimension(600, 0, 0, 800) = (%d, %d), want (600, 0)", got.width, got.height) + } +} + +func TestComputeMissingDimensionZeroNativeHeight(t *testing.T) { + t.Parallel() + got := computeMissingDimension(0, 400, 1200, 0) + if got.width != 0 || got.height != 400 { + t.Fatalf("computeMissingDimension(0, 400, 1200, 0) = (%d, %d), want (0, 400)", got.width, got.height) + } +} + +func TestComputeMissingDimensionRounding(t *testing.T) { + t.Parallel() + got := computeMissingDimension(999, 0, 1000, 333) + want := (999*333 + 500) / 1000 + if got.height != want { + t.Fatalf("computeMissingDimension(999, 0, 1000, 333).height = %d, want %d (rounded)", got.height, want) + } +} + func TestDocMediaInsertValidateFileView(t *testing.T) { t.Parallel() diff --git a/skills/lark-doc/references/lark-doc-media-insert.md b/skills/lark-doc/references/lark-doc-media-insert.md index b5b8879eb..9b557b701 100644 --- a/skills/lark-doc/references/lark-doc-media-insert.md +++ b/skills/lark-doc/references/lark-doc-media-insert.md @@ -67,6 +67,12 @@ lark-cli docs +media-insert --doc doxcnXXX --file ./spec.pdf --type file # 图片对齐与描述(caption) lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --align center --caption "架构图" + +# Insert image with explicit display width (height auto-computed from aspect ratio) +lark-cli docs +media-insert --doc doxcnXXX --file ./banner.png --width 800 --align center + +# Insert image with explicit width and height +lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --width 800 --height 447 --caption "architecture diagram" ``` ## 参数 @@ -79,6 +85,8 @@ lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --align center --cap | `--type ` | 否 | `image`(默认)或 `file`。`--from-clipboard` 目前只产出 image。 | | `--align ` | 否 | 仅图片:`left` / `center`(默认)/ `right` | | `--caption ` | 否 | 仅图片:图片描述 | +| `--width ` | 否 | Image display width in pixels (only for `--type=image`). If `--height` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. | +| `--height ` | 否 | Image display height in pixels (only for `--type=image`). If `--width` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. | > [!IMPORTANT] > 如果上一步是 [`lark-doc-create`](lark-doc-create.md),并且它在知识库/知识空间场景下返回的是 `/wiki/...` 形式的 `doc_url`,后续调用 `docs +media-insert` 时应优先传 `doc_id`,不要直接传这个 `doc_url`。 From 5778adfefa0bb0f2ddae2b6f7e4919ef2b5a9f05 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Fri, 15 May 2026 18:32:58 +0800 Subject: [PATCH 04/61] fix(drive): preserve parent token on nested overwrite (#908) * fix(drive): preserve parent token on nested overwrite Ensure drive +push overwrite requests for nested files keep parent_node aligned with the actual remote parent folder and report parent resolution failures explicitly. * test(drive): cover nested overwrite push workflow Add a live drive +push workflow case for overwriting a nested remote file so the PR parent-token fix is exercised against the real backend and verified to converge via +status. --- shortcuts/drive/drive_push.go | 13 +- shortcuts/drive/drive_push_test.go | 124 ++++++++++++++++++ .../drive_duplicate_sync_workflow_test.go | 65 +++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go index bc790653c..78c34e67c 100644 --- a/shortcuts/drive/drive_push.go +++ b/shortcuts/drive/drive_push.go @@ -275,7 +275,14 @@ var DrivePush = common.Shortcut{ skipped++ continue } - token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken) + parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache) + if parentErr != nil { + items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()}) + failed++ + uploadFailed = true + continue + } + token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken) if upErr != nil { // Token contract on overwrite failure: an in-place // overwrite preserves the file's token, so the @@ -580,6 +587,10 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, return token, nil } +func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relPath string, folderCache map[string]string) (string, error) { + return drivePushEnsureFolder(ctx, runtime, rootFolderToken, drivePushParentRel(relPath), folderCache) +} + // drivePushUploadFile uploads (or overwrites) a single local file. When // existingToken is non-empty, the request adds the file_token form field to // trigger overwrite-with-version semantics on the backend; the response is diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go index ec71e4bfa..3d5654ca2 100644 --- a/shortcuts/drive/drive_push_test.go +++ b/shortcuts/drive/drive_push_test.go @@ -1296,6 +1296,130 @@ func TestDrivePushReusesExistingRemoteFolder(t *testing.T) { } } +// TestDrivePushOverwriteNestedFileUsesParentFolderToken verifies that +// overwriting an existing nested remote file keeps parent_node aligned with +// the file's actual parent folder instead of the root folder token. +func TestDrivePushOverwriteNestedFileUsesParentFolderToken(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "fld_existing_sub", "name": "sub", "type": "folder"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=fld_existing_sub", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep_nested", "name": "keep.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_keep_nested", + "version": "v2", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != "tok_keep_nested" { + t.Fatalf("upload_all file_token = %q, want tok_keep_nested", got) + } + if got := body.Fields["parent_node"]; got != "fld_existing_sub" { + t.Fatalf("upload_all parent_node = %q, want fld_existing_sub", got) + } +} + +func TestDrivePushOverwriteNestedFileReportsParentEnsureFailure(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_keep_nested", "name": "sub/keep.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 9999, + "msg": "create parent failed", + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--if-exists", "overwrite", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"action": "failed"`) || !strings.Contains(stdout.String(), "create parent failed") { + t.Fatalf("expected failed item with create_folder error, got: %s", stdout.String()) + } +} + // TestDrivePushMirrorsEmptyDirectories confirms the gap codex review // flagged: a local directory with no files inside must still surface on // Drive as a created sub-folder, not be silently dropped because the diff --git a/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go index 16d3ea8fc..9946a99a1 100644 --- a/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go +++ b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go @@ -207,4 +207,69 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) { t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout) } }) + + t.Run("push overwrites nested remote file under its real parent", func(t *testing.T) { + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-nested-push-"+suffix, "") + subFolderToken := createDriveFolder(t, parentT, ctx, "sub", folderToken) + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local", "sub"), 0o755); err != nil { + t.Fatalf("mkdir local/sub: %v", err) + } + if err := os.WriteFile(filepath.Join(workDir, "local", "sub", "keep.txt"), []byte("local-nested-overwrite"), 0o644); err != nil { + t.Fatalf("write local/sub/keep.txt: %v", err) + } + + existingToken := uploadNamedFile(t, workDir, subFolderToken, "_nested_keep.txt", "keep.txt", "remote-before") + + pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--folder-token", folderToken, + "--if-exists", "overwrite", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + pushResult.AssertExitCode(t, 0) + pushResult.AssertStdoutStatus(t, true) + + if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 { + t.Fatalf("nested +push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout) + } + if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").action`).String(); got != "overwritten" { + t.Fatalf("nested +push action=%q, want overwritten\nstdout:\n%s", got, pushResult.Stdout) + } + if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").file_token`).String(); got != existingToken { + t.Fatalf("nested +push file_token=%q, want existing token %q\nstdout:\n%s", got, existingToken, pushResult.Stdout) + } + + statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + skipDriveStatusExactIfMissingDownloadScope(t, statusResult) + statusResult.AssertExitCode(t, 0) + statusResult.AssertStdoutStatus(t, true) + if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 { + t.Fatalf("nested +status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout) + } + if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "sub/keep.txt" { + t.Fatalf("nested +status unchanged rel_path=%q, want sub/keep.txt\nstdout:\n%s", got, statusResult.Stdout) + } + if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 || + gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 || + gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 { + t.Fatalf("nested overwrite should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout) + } + }) } From caff780c177221d3da4cd8fb10baaf77164db55d Mon Sep 17 00:00:00 2001 From: mazhe-nerd <106217973+mazhe-nerd@users.noreply.github.com> Date: Fri, 15 May 2026 20:53:59 +0800 Subject: [PATCH 05/61] feat(config): lark-channel secret supports SecretInput protocol (#912) --- cmd/config/bind_test.go | 20 +++++++ cmd/config/binder.go | 13 ++++- internal/binding/lark_channel.go | 21 +++++--- internal/binding/lark_channel_test.go | 77 +++++++++++++++++++++++++-- 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index bace505d7..ae6ad8a2d 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -408,6 +408,26 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) { } } +// Env template form: secret = "${VAR}" should resolve via the SecretInput +// pipeline (same path openclaw uses), so the keychain receives the env value +// not the literal template string. +func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("LARK_APP_SECRET", "resolved_via_env") + writeLarkChannelFixture(t, fakeHome, + `{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil { + t.Fatalf("expected success, got error: %v", err) + } +} + // tenant: "lark" should land as Brand("lark"), not normalized to "feishu". func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) { saveWorkspace(t) diff --git a/cmd/config/binder.go b/cmd/config/binder.go index ee1780840..8a9426674 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -312,13 +312,22 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { return nil, output.Errorf(output.ExitInternal, "lark-channel", "internal: appID %q does not match config", appID) } - if b.cfg.Accounts.App.Secret == "" { + if b.cfg.Accounts.App.Secret.IsZero() { return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", fmt.Sprintf("accounts.app.secret is empty in %s", b.path), "run lark-channel-bridge's setup to populate the app credential") } - stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain) + // Resolve through the same SecretInput pipeline openclaw uses, so + // bridge configs can use ${VAR} / env / file / exec just like openclaw. + secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv) + if err != nil { + return nil, output.ErrWithHint(output.ExitValidation, "lark-channel", + fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err), + fmt.Sprintf("check appSecret configuration in %s", b.path)) + } + + stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { return nil, output.Errorf(output.ExitInternal, "lark-channel", "keychain unavailable: %v", err) diff --git a/internal/binding/lark_channel.go b/internal/binding/lark_channel.go index 511f19dd7..f80afb53a 100644 --- a/internal/binding/lark_channel.go +++ b/internal/binding/lark_channel.go @@ -15,6 +15,11 @@ import ( // Unknown fields are ignored — forward-compatible with future bridge versions. type LarkChannelRoot struct { Accounts LarkChannelAccounts `json:"accounts"` + // Secrets is an optional registry of secret providers — same shape as + // openclaw's `secrets` block. Lets bridge declare `exec` provider scripts + // (for AES-encrypted secret backends), `env` allowlists, or `file` + // indirection rules. Resolved by binding.ResolveSecretInput. + Secrets *SecretsConfig `json:"secrets,omitempty"` } // LarkChannelAccounts is the namespace for credential entries. @@ -26,13 +31,17 @@ type LarkChannelAccounts struct { } // LarkChannelApp is the bot app credential entry. -// Bridge stores the secret as plain text — secret-resolve indirection -// (${VAR} / file: / exec:) is intentionally not supported here, matching -// the bridge's on-disk format. +// +// `Secret` accepts the full SecretInput protocol (string / "${VAR}" template / +// SecretRef object with source env|file|exec) so users can keep secrets out +// of config.json — either by referencing an env var the bridge inherits, a +// chmod-0400 file outside the bridge dir, or an exec script that decrypts a +// local AES-encrypted secret store. Aligns lark-channel with the same secret +// protocol openclaw already uses. type LarkChannelApp struct { - ID string `json:"id"` - Secret string `json:"secret"` - Tenant string `json:"tenant"` // "feishu" | "lark" + ID string `json:"id"` + Secret SecretInput `json:"secret"` + Tenant string `json:"tenant"` // "feishu" | "lark" } // ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json. diff --git a/internal/binding/lark_channel_test.go b/internal/binding/lark_channel_test.go index 2883144b5..4908556b4 100644 --- a/internal/binding/lark_channel_test.go +++ b/internal/binding/lark_channel_test.go @@ -24,8 +24,11 @@ func TestReadLarkChannelConfig_Valid(t *testing.T) { if got := root.Accounts.App.ID; got != "cli_abc123" { t.Errorf("ID = %q, want %q", got, "cli_abc123") } - if got := root.Accounts.App.Secret; got != "plain_secret" { - t.Errorf("Secret = %q, want %q", got, "plain_secret") + if got := root.Accounts.App.Secret.Plain; got != "plain_secret" { + t.Errorf("Secret.Plain = %q, want %q", got, "plain_secret") + } + if root.Accounts.App.Secret.Ref != nil { + t.Errorf("expected Plain form, got SecretRef = %+v", root.Accounts.App.Secret.Ref) } if got := root.Accounts.App.Tenant; got != "feishu" { t.Errorf("Tenant = %q, want %q", got, "feishu") @@ -92,8 +95,74 @@ func TestReadLarkChannelConfig_PartialFields(t *testing.T) { if root.Accounts.App.ID != "" { t.Errorf("expected empty ID, got %q", root.Accounts.App.ID) } - if root.Accounts.App.Secret != "" { - t.Errorf("expected empty Secret, got %q", root.Accounts.App.Secret) + if !root.Accounts.App.Secret.IsZero() { + t.Errorf("expected zero Secret, got %+v", root.Accounts.App.Secret) + } +} + +func TestReadLarkChannelConfig_SecretEnvTemplate(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + data := `{"accounts":{"app":{"id":"cli_a","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}` + if err := os.WriteFile(p, []byte(data), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + root, err := ReadLarkChannelConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := root.Accounts.App.Secret.Plain; got != "${LARK_APP_SECRET}" { + t.Errorf("Secret.Plain = %q, want template string", got) + } +} + +func TestReadLarkChannelConfig_SecretRefExec(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + data := `{ + "accounts": { + "app": { + "id": "cli_a", + "secret": {"source": "exec", "provider": "decrypt", "id": "app-cli_a"}, + "tenant": "feishu" + } + }, + "secrets": { + "providers": { + "decrypt": {"source": "exec", "command": "/usr/local/bin/lark-channel-bridge", "args": ["secrets", "get"]} + } + } + }` + if err := os.WriteFile(p, []byte(data), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + root, err := ReadLarkChannelConfig(p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if root.Accounts.App.Secret.Ref == nil { + t.Fatal("expected SecretRef, got Plain") + } + if got := root.Accounts.App.Secret.Ref.Source; got != "exec" { + t.Errorf("Secret.Ref.Source = %q, want %q", got, "exec") + } + if got := root.Accounts.App.Secret.Ref.ID; got != "app-cli_a" { + t.Errorf("Secret.Ref.ID = %q, want %q", got, "app-cli_a") + } + if root.Secrets == nil || root.Secrets.Providers["decrypt"] == nil { + t.Errorf("expected secrets.providers[decrypt] to be parsed") + } +} + +func TestReadLarkChannelConfig_SecretRefInvalidSource(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "config.json") + data := `{"accounts":{"app":{"id":"cli_a","secret":{"source":"bogus","id":"x"},"tenant":"feishu"}}}` + if err := os.WriteFile(p, []byte(data), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + if _, err := ReadLarkChannelConfig(p); err == nil { + t.Fatal("expected error for invalid secret source, got nil") } } From 14a3213038eaf563fb2c1234fe20c92981cc8ed9 Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Fri, 15 May 2026 20:55:43 +0800 Subject: [PATCH 06/61] chore(release): v1.0.32 (#918) Change-Id: I3d1a8ec4faf1ce585fb9eae45287bf02586e3e90 --- CHANGELOG.md | 19 +++++++++++++++++++ package.json | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af91bad8..8d17de395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [v1.0.32] - 2026-05-15 + +### Features + +- **doc**: Add `--width`/`--height` flags to `docs +media-insert` (#832) +- **wiki**: Add `+space-list` / `+node-list` / `+node-copy` shortcuts (#392) + +### Bug Fixes + +- **drive**: Preserve parent token on nested overwrite (#908) +- **selfupdate**: Use `LookPath` instead of `Executable` for binary verification (#886) +- **registry**: Wait for background meta refresh before test reset (#894) + +### Documentation + +- **doc**: Add SVG whiteboard support to `lark-doc` v2 skill (#901) +- **drive**: Add permission public patch error guidance (#863) + ## [v1.0.31] - 2026-05-14 ### Features @@ -703,6 +721,7 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32 [v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31 [v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30 [v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29 diff --git a/package.json b/package.json index da06bc69c..c2651294a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.31", + "version": "1.0.32", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" From 0b7215637fbacfbb831da8837e1028d9e03f3d08 Mon Sep 17 00:00:00 2001 From: Yuxuan Zhao Date: Fri, 15 May 2026 22:10:56 +0800 Subject: [PATCH 07/61] test: drop stale e2e yes flags (#920) --- tests/cli_e2e/calendar/calendar_create_event_test.go | 1 - tests/cli_e2e/calendar/calendar_personal_event_workflow_test.go | 1 - tests/cli_e2e/calendar/calendar_rsvp_workflow_test.go | 1 - tests/cli_e2e/calendar/calendar_update_event_test.go | 2 -- tests/cli_e2e/sheets/sheets_crud_workflow_test.go | 1 - tests/cli_e2e/sheets/sheets_filter_workflow_test.go | 1 - 6 files changed, 7 deletions(-) diff --git a/tests/cli_e2e/calendar/calendar_create_event_test.go b/tests/cli_e2e/calendar/calendar_create_event_test.go index e3353bf51..6f541c2aa 100644 --- a/tests/cli_e2e/calendar/calendar_create_event_test.go +++ b/tests/cli_e2e/calendar/calendar_create_event_test.go @@ -66,7 +66,6 @@ func TestCalendar_CreateEvent(t *testing.T) { "calendar_id": calendarID, "event_id": eventID, }, - Yes: true, }) clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr) }) diff --git a/tests/cli_e2e/calendar/calendar_personal_event_workflow_test.go b/tests/cli_e2e/calendar/calendar_personal_event_workflow_test.go index e9cbe3875..2bc329b1e 100644 --- a/tests/cli_e2e/calendar/calendar_personal_event_workflow_test.go +++ b/tests/cli_e2e/calendar/calendar_personal_event_workflow_test.go @@ -77,7 +77,6 @@ func TestCalendar_PersonalEventWorkflowAsUser(t *testing.T) { "calendar_id": calendarID, "event_id": eventID, }, - Yes: true, }) clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr) }) diff --git a/tests/cli_e2e/calendar/calendar_rsvp_workflow_test.go b/tests/cli_e2e/calendar/calendar_rsvp_workflow_test.go index 9a189bfff..b62f8923e 100644 --- a/tests/cli_e2e/calendar/calendar_rsvp_workflow_test.go +++ b/tests/cli_e2e/calendar/calendar_rsvp_workflow_test.go @@ -99,7 +99,6 @@ func TestCalendar_RSVPWorkflowAsUser(t *testing.T) { "calendar_id": calendarID, "event_id": eventID, }, - Yes: true, }) clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr) }) diff --git a/tests/cli_e2e/calendar/calendar_update_event_test.go b/tests/cli_e2e/calendar/calendar_update_event_test.go index 91ec1daa1..6ba83b273 100644 --- a/tests/cli_e2e/calendar/calendar_update_event_test.go +++ b/tests/cli_e2e/calendar/calendar_update_event_test.go @@ -68,7 +68,6 @@ func TestCalendar_UpdateEventWorkflow(t *testing.T) { "calendar_id": calendarID, "event_id": eventID, }, - Yes: true, }) clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr) }) @@ -126,7 +125,6 @@ func TestCalendar_UpdateEventWorkflow(t *testing.T) { "calendar_id": calendarID, "event_id": eventID, }, - Yes: true, }) require.NoError(t, err) result.AssertExitCode(t, 0) diff --git a/tests/cli_e2e/sheets/sheets_crud_workflow_test.go b/tests/cli_e2e/sheets/sheets_crud_workflow_test.go index 5fb4eb314..688dd371b 100644 --- a/tests/cli_e2e/sheets/sheets_crud_workflow_test.go +++ b/tests/cli_e2e/sheets/sheets_crud_workflow_test.go @@ -240,7 +240,6 @@ func TestSheets_SpreadsheetsResource(t *testing.T) { DefaultAs: "bot", Params: map[string]any{"spreadsheet_token": spreadsheetToken}, Data: map[string]any{"title": updatedTitle}, - Yes: true, }) require.NoError(t, err) result.AssertExitCode(t, 0) diff --git a/tests/cli_e2e/sheets/sheets_filter_workflow_test.go b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go index c65143b3f..41f7565f3 100644 --- a/tests/cli_e2e/sheets/sheets_filter_workflow_test.go +++ b/tests/cli_e2e/sheets/sheets_filter_workflow_test.go @@ -141,7 +141,6 @@ func TestSheets_FilterWorkflow(t *testing.T) { "sheet_id": sheetID, }, Data: filterData, - Yes: true, }) require.NoError(t, err) result.AssertExitCode(t, 0) From 898e0eebfd3b7ae4172245b04074ef7312e58f36 Mon Sep 17 00:00:00 2001 From: liujinkun2025 <77097548+liujinkun2025@users.noreply.github.com> Date: Fri, 15 May 2026 22:21:32 +0800 Subject: [PATCH 08/61] docs(lark-wiki): correct the --as default-identity claim (#919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill doc claimed wiki list/copy shortcuts default to --as user, but the CLI --as default is `auto` (no --as commonly resolves to bot, listing the app's spaces instead of the user's). Running `wiki +space-list` without --as therefore returns app-scoped data, contradicting the doc. Following the established lark-mail convention (concise user-centric guidance, not a precedence essay): - add a short "优先使用 user 身份" section to SKILL.md - fix the --as rows in lark-wiki-space-list / node-list / node-copy references to show the real `auto` default and steer to --as user Change-Id: I539f8d622c1bbad57f8a64c2fc7b7ecc0dfe2116 --- skills/lark-wiki/SKILL.md | 4 ++++ skills/lark-wiki/references/lark-wiki-node-copy.md | 2 +- skills/lark-wiki/references/lark-wiki-node-list.md | 2 +- skills/lark-wiki/references/lark-wiki-space-list.md | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 842c253cb..dbe4cf0a9 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -18,6 +18,10 @@ metadata: > - 遇到“部门 + --as bot”时,禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。 > - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。 +## 身份选择:优先使用 user 身份 + +知识空间和节点都是用户的个人资源,**策略上应优先显式使用 `--as user`**(CLI 的 `--as` 默认值为 `auto`,不带 `--as` 时常被解析成 `bot`,列出的是应用所属空间而非用户的)。仅当用户明确要求“应用 / bot 视角”时才用 `--as bot`(仍受上面的成员管理硬限制约束)。 + ## 快速决策 - 用户给的是知识库 URL(`.../wiki/`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":""}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。 diff --git a/skills/lark-wiki/references/lark-wiki-node-copy.md b/skills/lark-wiki/references/lark-wiki-node-copy.md index ebd3ab26a..7eb150919 100644 --- a/skills/lark-wiki/references/lark-wiki-node-copy.md +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -27,7 +27,7 @@ lark-cli wiki +node-copy \ | `--title` | No | New title for the copied node. Omit to keep the original title | | `--yes` | **Yes** | Confirm the high-risk operation. Without this flag the shortcut refuses to send the API request | | `--format` | No | Output format: `json` (default) / `pretty` / `table` / `csv` / `ndjson` | -| `--as` | No | Identity: `user` or `bot` (default: `user`) | +| `--as` | No | Identity `user`/`bot` (default `auto`); wiki is user-centric → pass `--as user` | > At least one of `--target-space-id` or `--target-parent-node-token` must be provided. diff --git a/skills/lark-wiki/references/lark-wiki-node-list.md b/skills/lark-wiki/references/lark-wiki-node-list.md index ebf12c5c9..862cfa1e6 100644 --- a/skills/lark-wiki/references/lark-wiki-node-list.md +++ b/skills/lark-wiki/references/lark-wiki-node-list.md @@ -38,7 +38,7 @@ lark-cli wiki +node-list --space-id --format pretty | `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) | | `--page-limit` | int | No | 10 | Max pages with `--page-all` (0 = unlimited) | | `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` | -| `--as` | enum | No | `user` | Identity: `user` or `bot` | +| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` (`my_library` requires `--as user`) | ## Output diff --git a/skills/lark-wiki/references/lark-wiki-space-list.md b/skills/lark-wiki/references/lark-wiki-space-list.md index 0662cc3b7..ab3d657a1 100644 --- a/skills/lark-wiki/references/lark-wiki-space-list.md +++ b/skills/lark-wiki/references/lark-wiki-space-list.md @@ -31,7 +31,7 @@ lark-cli wiki +space-list --format table | `--page-all` | bool | `false` | Automatically paginate through all pages (capped by `--page-limit`) | | `--page-limit` | int | 10 | Max pages with `--page-all` (0 = unlimited) | | `--format` | enum | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` | -| `--as` | enum | `user` | Identity: `user` or `bot` | +| `--as` | enum | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` | ## Output From 7bad9f2656c8b9960a5242ac1dd557471fd5fb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E7=9B=88=E8=BE=89=E5=BE=8B=E5=B8=88?= Date: Mon, 18 May 2026 11:48:09 +0800 Subject: [PATCH 09/61] fix: guide agents to yield during auth device flow (#933) --- cmd/auth/auth_test.go | 26 ++++++++++++++++++++++++++ cmd/auth/login.go | 8 +++++--- cmd/auth/login_messages.go | 4 ++-- cmd/auth/login_messages_test.go | 15 ++++++++------- cmd/auth/login_test.go | 25 ++++++++++++++++++++++--- skills/lark-shared/SKILL.md | 14 ++++++++++---- 6 files changed, 73 insertions(+), 19 deletions(-) diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 41a775145..c2b1940ff 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -44,6 +44,32 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) { } } +func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil }) + cmd.SetOut(stdout) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := stdout.String() + for _, want := range []string{ + "only delivers final turn messages", + "--no-wait --json", + "send the verification URL to the user as your final message", + "run --device-code in a later step", + } { + if !strings.Contains(got, want) { + t.Fatalf("help missing %q, got:\n%s", want, got) + } + } +} + func TestAuthCheckCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 6a27ee6cf..ef0e455ca 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -47,7 +47,9 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra. Long: `Device Flow authorization login. For AI agents: this command blocks until the user completes authorization in the -browser. Run it in the background and retrieve the verification URL from its output.`, +browser. If your harness only delivers final turn messages, use --no-wait --json, +send the verification URL to the user as your final message, end the turn, then +run --device-code in a later step after the user confirms authorization.`, RunE: func(cmd *cobra.Command, args []string) error { if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { return output.ErrWithHint(output.ExitValidation, "strict_mode", @@ -187,7 +189,7 @@ func authLoginRun(opts *LoginOptions) error { log("View all options:") log(msg.HintFooter) log("") - log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.") + log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.") return output.ErrValidation("please specify the scopes to authorize") } } @@ -266,7 +268,7 @@ func authLoginRun(opts *LoginOptions) error { "verification_url": authResp.VerificationUriComplete, "device_code": authResp.DeviceCode, "expires_in": authResp.ExpiresIn, - "hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. Then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode), + "hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode), } encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 548d704b2..189c42740 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{ OpenURL: "在浏览器中打开以下链接进行认证:\n\n", WaitingAuth: "等待用户授权...", - AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code ` 续上轮询。**不要短 timeout 反复重试**,每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。", + AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code ` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。", AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...", LoginSuccess: "授权成功! 用户: %s (%s)", AuthorizedUser: "当前授权账号: %s (%s)", @@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{ OpenURL: "Open this URL in your browser to authenticate:\n\n", WaitingAuth: "Waiting for user authorization...", - AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code ` to resume polling. **Do NOT retry with a short timeout**; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.", + AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code ` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.", AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...", LoginSuccess: "Authorization successful! User: %s (%s)", AuthorizedUser: "Authorized account: %s (%s)", diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go index 9471f344b..3c5cc1c88 100644 --- a/cmd/auth/login_messages_test.go +++ b/cmd/auth/login_messages_test.go @@ -97,16 +97,17 @@ func TestLoginMsg_FormatStrings(t *testing.T) { } // TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous -// auth-login output tells AI agents two things: (a) this command blocks for -// minutes — set a long runner timeout, and (b) the alternative is the -// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and -// kills the process before the user can authorize; without (b) the AI has no -// recovery path and just retries with the same short timeout, invalidating -// each new device code in turn. +// auth-login output tells AI agents three things: (a) this command blocks for +// minutes — set a long runner timeout, (b) the alternative is the --no-wait + +// --device-code split-flow, and (c) non-streaming harnesses must end the turn +// after presenting the URL instead of blocking in the same turn. func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) { for _, lang := range []string{"zh", "en"} { hint := getLoginMsg(lang).AgentTimeoutHint - for _, want := range []string{"--no-wait", "--device-code"} { + for _, want := range []string{"--no-wait", "--device-code", "turn"} { + if lang == "zh" && want == "turn" { + want = "本轮" + } if !strings.Contains(hint, want) { t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 8687d313a..51ebdb9d9 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -315,10 +315,12 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { if !strings.Contains(msg, "scopes") { t.Errorf("expected error to mention scopes, got: %s", msg) } - // Stderr should contain background hint + // Stderr should explain the split-flow path for non-streaming agents. stderrStr := stderr.String() - if !strings.Contains(stderrStr, "background") { - t.Errorf("expected stderr to mention background, got: %s", stderrStr) + for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} { + if !strings.Contains(stderrStr, want) { + t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr) + } } } @@ -949,11 +951,24 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) { "do not add %20, spaces, or punctuation", "do not wrap it as Markdown link text", "fenced code block containing only the raw URL", + "final message of the turn", + "return control to the user", + "do not block on --device-code in the same turn", + "After the user confirms authorization in a later step", + "lark-cli auth login --device-code device-code", } { if !strings.Contains(hint, want) { t.Fatalf("hint missing %q, got:\n%s", want, hint) } } + for _, unwanted := range []string{ + "Then immediately execute", + "Do not instruct the user to run this command themselves", + } { + if strings.Contains(hint, unwanted) { + t.Fatalf("hint should not contain %q, got:\n%s", unwanted, hint) + } + } } func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) { @@ -1035,6 +1050,10 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t * hint, _ := data["agent_hint"].(string) for _, want := range []string{ "timeout >= 600s", + "本轮最终消息", + "结束本轮", + "用户回复已完成授权", + "不要在同一轮里展示 URL 后立刻阻塞执行 --device-code", "逐字原样转发 CLI 返回的 URL", "opaque string", "不要做 URL 编码或解码", diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index f4da07084..c1ed2bd77 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -66,14 +66,20 @@ lark-cli auth login --scope "" # 按具体 scope 授权(推 #### Agent 代理发起认证(推荐) -当你作为 AI agent 需要帮用户完成认证时,使用background方式 执行以下命令发起授权流程, 并将授权链接原样发给用户: +当你作为 AI agent 需要帮用户完成认证时,优先使用 split-flow,避免在同一轮对话中阻塞等待用户授权: ```bash -# 发起授权(阻塞直到用户授权完成或过期) -lark-cli auth login --scope "calendar:calendar:readonly" - +# 发起授权(立即返回 device_code 和 verification_url) +lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json ``` +拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行 `--device-code` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。 + +用户回复已完成授权后,再在后续步骤执行: + +```bash +lark-cli auth login --device-code +``` ## 更新检查 From ca6c6c3e291974e3a6c6c3b0396e446cc436f681 Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Mon, 18 May 2026 13:34:31 +0800 Subject: [PATCH 10/61] fix: mark base field update high risk (#936) --- shortcuts/base/base_execute_test.go | 2 +- shortcuts/base/base_shortcuts_test.go | 6 ++++++ shortcuts/base/field_update.go | 2 +- skills/lark-base/SKILL.md | 4 ++-- skills/lark-base/references/formula-field-guide.md | 2 ++ .../lark-base/references/lark-base-field-update.md | 12 +++++++++--- .../lark-base-shortcut-field-properties.md | 4 ++-- skills/lark-base/references/lookup-field-guide.md | 2 ++ 8 files changed, 25 insertions(+), 9 deletions(-) diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 741b2f0e3..80463ff46 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -432,7 +432,7 @@ func TestBaseFieldExecuteUpdate(t *testing.T) { "data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"}, }, }) - if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`}, factory, stdout); err != nil { + if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) { diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index eeca3b8d1..7a9df7c8d 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -167,6 +167,12 @@ func TestBaseTableDeleteRisk(t *testing.T) { } } +func TestBaseFieldUpdateRisk(t *testing.T) { + if BaseFieldUpdate.Risk != "high-risk-write" { + t.Fatalf("risk=%q want=%q", BaseFieldUpdate.Risk, "high-risk-write") + } +} + func TestBaseDeleteShortcutsRisk(t *testing.T) { cases := map[string]string{ BaseFieldDelete.Command: BaseFieldDelete.Risk, diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go index 03999c022..f8e8a47d0 100644 --- a/shortcuts/base/field_update.go +++ b/shortcuts/base/field_update.go @@ -13,7 +13,7 @@ var BaseFieldUpdate = common.Shortcut{ Service: "base", Command: "+field-update", Description: "Update a field by ID or name", - Risk: "write", + Risk: "high-risk-write", Scopes: []string{"base:field:update"}, AuthTypes: authTypes(), Flags: []common.Flag{ diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index b3585ad48..2abc724ab 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -96,7 +96,7 @@ metadata: | 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 | |------|------------------|----------------|----------| | `+field-list / +field-get` | 列出字段结构,或获取单个字段详情 | [`lark-base-field-list.md`](references/lark-base-field-list.md)、[`lark-base-field-get.md`](references/lark-base-field-get.md) | 写记录、写字段、做分析前常先读 `+field-list`;`+field-list` 只能串行执行;`+field-get` 适合删除/更新前确认目标 | -| `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果涉及类型转换,直接按 `+field-update` 中的字段类型变更规则执行,只在安全白名单内考虑原地转换;如果类型是 `formula / lookup`,先转去读对应 guide;删除时用户已明确目标可直接执行并带 `--yes` | +| `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果涉及类型转换,直接按 `+field-update` 中的字段类型变更规则执行,只在安全白名单内考虑原地转换;如果类型是 `formula / lookup`,先转去读对应 guide;更新或删除时用户已明确目标可直接执行并带 `--yes` | | `+field-search-options` | 查询字段可选项 | [`lark-base-field-search-options.md`](references/lark-base-field-search-options.md) | 适合单选/多选等选项型字段 | #### 2.3.3 Record 子模块 @@ -326,7 +326,7 @@ lark-cli auth login --domain base ### 4.4 确认与回复规则 - 视图重命名时,用户已明确“把哪个视图改成什么名字”时,`+view-rename` 直接执行即可。 -- 删除记录 / 字段 / 表时,如果用户已经明确说要删除,且目标明确,`+record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。 +- 更新字段或删除记录 / 字段 / 表时,如果用户已经明确目标,`+field-update / +record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。 - 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。 - `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。 - 若 Base 由 bot 身份创建或复制,shortcut 会自动尝试为当前 CLI 用户补授 `full_access`,并在输出中返回 `permission_grant`;agent 不需要再手动编排单独授权。owner 转移必须单独确认,禁止擅自执行。 diff --git a/skills/lark-base/references/formula-field-guide.md b/skills/lark-base/references/formula-field-guide.md index df89fdb73..5d9a46350 100644 --- a/skills/lark-base/references/formula-field-guide.md +++ b/skills/lark-base/references/formula-field-guide.md @@ -6,6 +6,8 @@ When creating or updating a formula field with `lark-cli base +field-create/+fie Do **not** proactively add `--i-have-read-guide` before reading this guide. Without it, the CLI will fail fast and direct you back to this guide. +When using `+field-update`, also pass `--yes`: field update is a high-risk `PUT` operation because changing a field definition can affect the whole column. + ## Default strategy **All cross-table references, aggregations, and computed fields should use Formula fields by default.** Do NOT use Lookup fields unless the user explicitly requests it. Formula is a strict superset of Lookup — anything Lookup can do, Formula can do with a single expression. diff --git a/skills/lark-base/references/lark-base-field-update.md b/skills/lark-base/references/lark-base-field-update.md index c81370609..76511a4d2 100644 --- a/skills/lark-base/references/lark-base-field-update.md +++ b/skills/lark-base/references/lark-base-field-update.md @@ -11,13 +11,15 @@ lark-cli base +field-update \ --base-token \ --table-id \ --field-id \ - --json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' + --json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' \ + --yes lark-cli base +field-update \ --base-token \ --table-id \ --field-id \ - --json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人"}' + --json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人"}' \ + --yes ``` ## 参数 @@ -28,6 +30,10 @@ lark-cli base +field-update \ | `--table-id ` | 是 | 表 ID 或表名 | | `--field-id ` | 是 | 字段 ID 或字段名 | | `--json ` | 是 | 字段属性 JSON 对象 | +| `--yes` | 是 | 确认执行高风险字段更新 | + +> 这是**高风险写入操作**。`+field-update` 使用 `PUT` 全量字段定义语义;改变字段类型或关键配置可能影响整列已有数据的解释、展示或可用性。CLI 层要求显式传 `--yes`;如果用户已经明确目标和期望更新,可直接执行并带上 `--yes`。 + ## API 入参详情 **HTTP 方法和路径:** @@ -154,7 +160,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id ## 坑点 - ⚠️ 这是全量字段属性更新语义,不是 patch。 -- ⚠️ 这是写入操作,执行前必须确认。 +- ⚠️ 这是高风险写入操作,执行时必须带 `--yes`。 - ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先阅读对应指南再执行。 ## 参考 diff --git a/skills/lark-base/references/lark-base-shortcut-field-properties.md b/skills/lark-base/references/lark-base-shortcut-field-properties.md index a794e8853..ea6ebb1a3 100644 --- a/skills/lark-base/references/lark-base-shortcut-field-properties.md +++ b/skills/lark-base/references/lark-base-shortcut-field-properties.md @@ -10,7 +10,7 @@ - 顶层统一使用:`type` + `name` + 类型特有字段。 - 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。 - 不要使用旧结构:`field_name`、`property`、`ui_type`、数字枚举 `type`。 -- `+field-update` 使用同样的字段 JSON 结构,但语义是 `PUT`;建议先 `+field-get` 再按目标状态全量提交。 +- `+field-update` 使用同样的字段 JSON 结构,但语义是 `PUT`;这是高风险写入操作,建议先 `+field-get` 再按目标状态全量提交,并带 `--yes`。 - `type=formula` 或 `type=lookup` 创建/更新前,必须先读对应 guide。 推荐示例: @@ -471,7 +471,7 @@ ## 4. 创建与更新 - `+field-create`:按目标字段配置直接构造 `--json`。 -- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交。 +- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。 ## 5. 易错点 diff --git a/skills/lark-base/references/lookup-field-guide.md b/skills/lark-base/references/lookup-field-guide.md index 27d22f1b2..499aa0539 100644 --- a/skills/lark-base/references/lookup-field-guide.md +++ b/skills/lark-base/references/lookup-field-guide.md @@ -6,6 +6,8 @@ When creating or updating a lookup field with `lark-cli base +field-create/+fiel Do **not** proactively add `--i-have-read-guide` before reading this guide. Without it, the CLI will fail fast and direct you back to this guide. +When using `+field-update`, also pass `--yes`: field update is a high-risk `PUT` operation because changing a field definition can affect the whole column. + ## Default strategy **Use Formula fields by default for cross-table references and aggregations.** Only use Lookup fields when the user explicitly requests a Lookup field. Formula is a strict superset of Lookup — anything Lookup can do, Formula can do with a single expression. From 33c292c05e1e06b271df879e501edb0c984cb53a Mon Sep 17 00:00:00 2001 From: sang-neo03 Date: Mon, 18 May 2026 15:25:02 +0800 Subject: [PATCH 11/61] feat(extension): Plugin / Hook framework with command pruning (#910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extension): introduce Plugin / Hook framework with command pruning Add a single public extension contract under extension/platform: integrators implement the Plugin interface and register Observers, Wrappers, Lifecycle handlers, and pruning Rules through the Registrar in one Install call. Command pruning: - Rule (Allow / Deny / MaxRisk / Identities) with doublestar globs - 4-axis AND evaluation, parent-group aggregation, unknown-risk allow - Sources: Plugin.Restrict (single-rule) and ~/.lark-cli/policy.yml - Plugin path is fail-closed (envelope on rule error / multiple Restrict); yaml path is fail-open (warning, CLI continues) - strict-mode stubs now also write the denial annotation so the hook layer's denial guard physically isolates Wrap chains on them - HOME path never leaked through policy_source label Hook framework: - Observer (panic-safe, Before/After), Wrapper (middleware, may short-circuit via AbortError), Lifecycle (Startup + Shutdown only) - Recover guards every plugin entry point: Capabilities(), Install(), Wrapper factory composition AND inner Handler, Lifecycle handlers - namespacedWrap copies AbortError so a plugin's package-level sentinel is never mutated across concurrent invocations - Selector unknown-risk uniform: ByExactRisk / ByWrite / ByReadOnly never match unannotated commands; safety-side hooks opt in via ByWrite().Or(ByUnknownRisk()) Bootstrap orchestration (cmd/build.go + cmd/policy.go): - InstallAll uses a staging Registrar + atomic commit - FailClosed plugin install / Plugin.Restrict conflict / Startup handler failure each install a structured envelope guard at every dispatch path - walkGuard neutralises every cobra bypass we know of (PersistentPreRunE first-wins, ValidateArgs, ParseFlags, legacyArgs, __complete / __completeNoDesc, non-runnable groups, required-arg subcommands) - cmd/root.go::Execute calls hook.Emit(Shutdown, runErr) after rootCmd.Execute; isCompletionCommand skips both __complete and __completeNoDesc so Tab completion never triggers Shutdown handlers Capabilities consistency: - Restricts=true must declare FailurePolicy=FailClosed - RequiredCLIVersion (semver constraint) is validated against build.Version; a malformed constraint is treated as untrusted-config and aborts unconditionally, regardless of FailurePolicy (DEV builds included) JSON envelope contract: - error.type closed enum: pruning / strict_mode / hook / plugin_install / plugin_conflict / plugin_lifecycle - reason_code closed enums per type, all referenced by structured tests Bootstrap surfaces (new user commands): - lark-cli config policy show -- JSON view of the active Rule + source - lark-cli config policy validate -- parse + schema + glob check, no apply Coverage: - extension/platform: every public type has a unit test - internal/{pruning,hook,platformhost,policydecision,cmdmeta}: full coverage of denial guard isolation, AbortError sentinel safety, observer panic safety, lifecycle error/panic typing, staging atomic rollback - cmd/plugin_integration_test.go: end-to-end through buildInternal with synthetic and real command trees - cmd/install_guard_test.go: walkGuard covers auth / config / __complete / __completeNoDesc / non-runnable parents * fix(pruning): deny stub must override Args + PersistentPreRunE The pruning denyStub and the strict-mode stub previously only swapped RunE plus Hidden + DisableFlagParsing. Cobra's dispatch order means several pre-RunE gates can fire BEFORE the stub's RunE ever runs: 1. Args validator: shortcut commands often declare cobra.NoArgs. With DisableFlagParsing=true the user's `--doc xxx --mode append` looks like positional args, so ValidateArgs surfaces a usage error instead of the pruning / strict_mode envelope. Observer hooks also miss the dispatch entirely. 2. Parent PersistentPreRunE: cmd/auth/auth.go declares a PersistentPreRunE that returns external_provider when env credentials are set. Cobra's "first PersistentPreRunE wins walking up from the leaf" then short-circuits with external_provider instead of the leaf's denial envelope. Both stubs now also set: - Args = cobra.ArbitraryArgs (bypass gate 1) - PersistentPreRunE = no-op leaf hook (bypass gate 2) - PreRunE / PreRun / PersistentPreRun = nil (defensive) Effect: dispatch reaches the wrapped RunE, observers fire, the real pruning / strict_mode envelope is emitted regardless of credential provider or flag count. Adds regression tests covering both gates on both stub paths. * fix(config): policy subcommand bypasses parent's credential check cmd/config/config.go::NewCmdConfig declares a PersistentPreRunE that calls f.RequireBuiltinCredentialProvider; with env credentials set, it returns external_provider for every config subcommand. `config policy show` and `config policy validate` are READ-ONLY diagnostic commands -- they inspect or parse the user-layer rule without touching credentials. They MUST work regardless of which credential provider is active, otherwise users on env-credential deployments cannot debug their policy. Same shape as the codex C11/C13 fix: install a no-op leaf-level PersistentPreRunE on the `policy` group so cobra's "first walking up from leaf" rule picks ours over the config parent's. Regression caught by divergent e2e (F1-F6 all returned external_provider before this fix; all pass after). Adds a unit test pinning the PersistentPreRunE override. * feat(shortcuts): tag service groups with cmdmeta.Domain RegisterShortcutsWithContext now calls cmdmeta.SetDomain on each service-level cobra.Command (im, docs, drive, calendar, ...) so the business-domain axis is actually populated on every shortcut leaf via parent-chain inheritance. Before this change, platform.ByDomain("docs") never matched any command: the domain annotation was unset across the entire shortcut tree, so the selector's d != "" guard always failed and risk-style selectors silently degraded to no-op. The SetDomain call is placed AFTER the create-or-reuse branch so it fires whether the service command was freshly created here or had already been added by cmd/service/service.go's OpenAPI auto- registration (which runs first and creates im, drive, calendar, etc.). Without this placement only pure-shortcut services like docs would have been tagged. Adds a regression test asserting: - service-group cobra.Command carries the cmdmeta.domain annotation - leaf shortcuts inherit the domain via parent-chain walk * feat(diagnostic): add unconditionally allowed command paths for introspection * feat(plugins): add diagnostic command to inspect installed plugins and their contributions * fix(cli): surface unknown_subcommand error instead of silent help fallback When a user passed an unknown subcommand or shortcut (e.g. `lark-cli drive +bogus`), cobra returned `flag.ErrHelp` for the non-runnable group command, printed the parent help, and exited 0. AI agents couldn't distinguish a typo from an intentional help request. Install a tree-wide guard that attaches a RunE to every group command without its own Run/RunE. The RunE forwards no-args invocations to help (preserving prior behavior) and emits a structured unknown_subcommand ExitError (exit 2) listing available subcommands when args are present. * refactor(envelope): rename error.type pruning/strict_mode to command_denied The envelope's `type` field was leaking implementation terms ("pruning", "strict_mode") that describe enforcement mechanism rather than the user- facing semantic. It also duplicated `detail.layer`, and forced consumers to branch on two values for the same conceptual error ("a command was denied by policy"). Collapse both into a single semantic type "command_denied". The enforcement layer ("pruning" / "strict_mode") is preserved in `detail.layer` so debugging and per-layer diagnostics still work. * feat(platform): fail closed on unannotated/invalid risk when a Rule is active The pruning engine used to treat any command without a risk annotation as ALLOW even when a Rule with MaxRisk was set, and would silently skip the MaxRisk comparison whenever the command's risk string was outside the closed taxonomy. Both gaps let an unannotated or typo'd write command slip past an "agent read-only" pruning rule. Engine now denies before any other axis when a Rule is registered: - reason_code "risk_not_annotated" for commands with no risk - reason_code "risk_invalid" for commands whose risk is outside the read | write | high-risk-write taxonomy (e.g. typo "wrtie") Main-flow is preserved: a nil Rule still returns Allowed=true unconditionally, so a CLI with no pruning plugin behaves identically to before. ByUnknownRisk() is removed from the public surface since the Unknown state is no longer reachable through risk-based selectors when any Rule is active; safety-side widening composition is no longer needed. * chore(config): hide diagnostic policy/plugins commands from --help `config policy show`, `config policy validate`, and `config plugins show` are local-introspection-only commands kept behind the pruning diagnostic whitelist so operators can always inspect why a command was denied. They do not need to surface in `--help` for AI agents and were contributing to help noise. Hide the `policy` and `plugins` parent groups and both `show` / `validate` leaves. Commands remain callable by exact name and continue to bypass user-layer pruning via diagnosticPaths. * style: gofmt * fix(platform): nil Selector honours None contract; reject multi-doc policy yaml - selector.go: And/Or/Not now treat nil Selector as None() per godoc, preventing runtime panic when composed selectors are invoked. - schema.go: Parse rejects multi-document YAML input so a stray '---' separator can't silently drop trailing policy constraints. * chore: go mod tidy * feat(extension/platform): plugin SDK with policy engine, hooks, and Builder Introduces extension/platform — the in-process plugin SDK external Go forks of lark-cli use to extend or restrict the command surface. Plugins compile in via blank import; there is no dynamic loading and no RPC isolation. Public SDK (extension/platform): - Plugin interface (Name / Version / Capabilities / Install). - Registrar verbs: Observe, Wrap, On, Restrict. - Hook types: Observer (side-effect, panic-safe, fires Before/After RunE), Wrapper (middleware, may short-circuit via AbortError), LifecycleHandler (Startup / Shutdown), Selector with nil-safe And/Or/Not composition. - Risk / Identity are defined string types with closed taxonomies; ParseRisk / ParseIdentity convert raw strings with the absent-vs-invalid distinction the engine relies on. - Builder ergonomic constructor (NewPlugin().Observer().Wrap() ...MustBuild()) that enforces name/hookName grammar, hookName uniqueness, and the Restrict ↔ FailClosed pairing regardless of call order. - Invocation is a read-only interface; the framework's concrete invocation type lives in internal/hook so plugins cannot fabricate denial / strict-mode / identity state. Args() returns a defensive copy on every call so hook mutation cannot leak into the original RunE. - CommandDeniedError + AbortError carry structured fields for the closed `command_denied` / `hook` envelope contract. - ResetForTesting gated behind //go:build testing. - README + godoc examples (Observer / Wrapper / Restrict) + two runnable example forks (audit-observer, readonly-policy). Host (internal/platform, internal/hook, internal/cmdpolicy): - InstallAll: staged plugin registration with atomic commit, panic isolation, FailOpen / FailClosed semantics, RequiredCLIVersion semver check, single-Restrict invariant, duplicate-plugin-name detection. - hook.Install wraps every runnable cmd.RunE with: Before observers (panic-safe) → denial guard → composed Wrap chain → original RunE → After observers (always fire, even on err). Denied commands physically bypass the Wrap chain so a plugin Wrapper cannot suppress or rewrite a denial; observers still see the attempt for audit. - Recover shim around plugin Wrappers converts panics (including the factory call) into a structured `hook` envelope with reason_code=panic; namespacing shim attributes AbortError to the namespaced hook name. - cmdpolicy (renamed from internal/pruning) is the user-layer command policy engine: walks the cobra tree, evaluates each runnable command against a Rule's four-axis filter (Allow / Deny / MaxRisk / Identities), produces parent-group aggregate denials, and installs denyStubs. Rule.AllowUnannotated opts out of the unannotated-deny gate for gradual adoption; risk_invalid typos always deny with an edit-distance "did you mean" suggestion. - Strict-mode stub in cmd/prune.go composes the shared detail.* / wrapped CommandDeniedError shape via cmdpolicy helpers (BuildDenialError / CommandDeniedFromDenial / DenialDetailMap), so command_denied envelopes from strict-mode and user-layer policy carry the same closed-enum fields (detail.layer / reason_code / policy_source). The historical short Message + independent Hint are preserved unchanged. - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml with KnownFields strict mode, including allow_unannotated. - `config policy show` / `config policy validate` and the plugin inventory diagnostic surface the resolved Rule (allow, deny, max_risk, identities, allow_unannotated) and the hook contributions per plugin. Envelope contract (docs/extension/reason-codes.md): - error.type is a closed set: command_denied, hook, plugin_install, plugin_conflict, plugin_lifecycle. - reason_code is a closed enum per error.type, dispatched on by external agents and CI integrations. - detail.layer = "policy" | "strict_mode" attributes the rejection. Build / CI: - Makefile unit-test / vet / coverage and ci.yml fast-gate + unit-test + coverage now pass -tags testing so register_testing.go is visible; ./extension/... is in the package list so the SDK's own tests actually run. - fmt-check and examples-build Makefile targets. - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob matching in Rule.Allow / Rule.Deny. Author-facing material: - docs/extension/ (quickstart, plugin-author-guide, reason-codes) is provided in the working tree but kept out of git tracking per repo convention (.gitignore covers docs/). Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703 * feat(extension/platform): plugin SDK with policy engine, hooks, and Builder Introduces extension/platform — the in-process plugin SDK external Go forks of lark-cli use to extend or restrict the command surface. Plugins compile in via blank import; there is no dynamic loading and no RPC isolation. Public SDK (extension/platform): - Plugin interface (Name / Version / Capabilities / Install). - Registrar verbs: Observe, Wrap, On, Restrict. - Hook types: Observer (side-effect, panic-safe, fires Before/After RunE), Wrapper (middleware, may short-circuit via AbortError), LifecycleHandler (Startup / Shutdown), Selector with nil-safe And/Or/Not composition. - Risk / Identity are defined string types with closed taxonomies; ParseRisk / ParseIdentity convert raw strings with the absent-vs-invalid distinction the engine relies on. - Builder ergonomic constructor (NewPlugin().Observer().Wrap() ...MustBuild()) that enforces name/hookName grammar, hookName uniqueness, and the Restrict ↔ FailClosed pairing regardless of call order. - Invocation is a read-only interface; the framework's concrete invocation type lives in internal/hook so plugins cannot fabricate denial / strict-mode / identity state. Args() returns a defensive copy on every call so hook mutation cannot leak into the original RunE. - CommandDeniedError + AbortError carry structured fields for the closed `command_denied` / `hook` envelope contract. - ResetForTesting gated behind //go:build testing. - README + godoc examples (Observer / Wrapper / Restrict) + two runnable example forks (audit-observer, readonly-policy). Host (internal/platform, internal/hook, internal/cmdpolicy): - InstallAll: staged plugin registration with atomic commit, panic isolation, FailOpen / FailClosed semantics, RequiredCLIVersion semver check, single-Restrict invariant, duplicate-plugin-name detection. - hook.Install wraps every runnable cmd.RunE with: Before observers (panic-safe) → denial guard → composed Wrap chain → original RunE → After observers (always fire, even on err). Denied commands physically bypass the Wrap chain so a plugin Wrapper cannot suppress or rewrite a denial; observers still see the attempt for audit. - Recover shim around plugin Wrappers converts panics (including the factory call) into a structured `hook` envelope with reason_code=panic; namespacing shim attributes AbortError to the namespaced hook name. - cmdpolicy (renamed from internal/pruning) is the user-layer command policy engine: walks the cobra tree, evaluates each runnable command against a Rule's four-axis filter (Allow / Deny / MaxRisk / Identities), produces parent-group aggregate denials, and installs denyStubs. Rule.AllowUnannotated opts out of the unannotated-deny gate for gradual adoption; risk_invalid typos always deny with an edit-distance "did you mean" suggestion. - Strict-mode stub in cmd/prune.go composes the shared detail.* / wrapped CommandDeniedError shape via cmdpolicy helpers (BuildDenialError / CommandDeniedFromDenial / DenialDetailMap), so command_denied envelopes from strict-mode and user-layer policy carry the same closed-enum fields (detail.layer / reason_code / policy_source). The historical short Message + independent Hint are preserved unchanged. - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml with KnownFields strict mode, including allow_unannotated. - `config policy show` / `config policy validate` and the plugin inventory diagnostic surface the resolved Rule (allow, deny, max_risk, identities, allow_unannotated) and the hook contributions per plugin. Envelope contract (docs/extension/reason-codes.md): - error.type is a closed set: command_denied, hook, plugin_install, plugin_conflict, plugin_lifecycle. - reason_code is a closed enum per error.type, dispatched on by external agents and CI integrations. - detail.layer = "policy" | "strict_mode" attributes the rejection. Build / CI: - Makefile unit-test / vet / coverage and ci.yml fast-gate + unit-test + coverage now pass -tags testing so register_testing.go is visible; ./extension/... is in the package list so the SDK's own tests actually run. - fmt-check and examples-build Makefile targets. - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob matching in Rule.Allow / Rule.Deny. Author-facing material: - docs/extension/ (quickstart, plugin-author-guide, reason-codes) is provided in the working tree but kept out of git tracking per repo convention (.gitignore covers docs/). Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703 * refactor(policy): remove validate command and update diagnostics * fix(extension/platform): address PR review must-fix items - cmdpolicy: skip AnnotationPureGroup commands in EvaluateAll, aggregateParents, and hasRunnableDescendant so user-layer policy no longer blocks ` --help` after the unknown-subcommand guard attaches RunE to every parent - cmd/root: tag guarded parent groups with AnnotationPureGroup - extension/platform: drop `//go:build testing` from register_testing.go so `go test ./...` works without an extra build tag - extension/platform/README: inline reason_code reference, fix plugin lifecycle diagram order (init/Register precede RegisteredPlugins) - cmd/platform_bootstrap: route userPolicyPath through core.GetBaseConfigDir so LARKSUITE_CLI_CONFIG_DIR is honoured - cmdpolicy: add RedactHomeDir helper, fold base config dir and $HOME prefixes for config policy show + resolver errors - internal/platform: reject unrecognised FailurePolicy values with invalid_capability instead of silently fail-open - cmd/config: surface diagnostic policy/plugins commands in `config --help` Long text - CHANGELOG: document command_denied error.type rename and unknown_subcommand exit-2 behavior change * fix(extension/platform): address CodeRabbit review comments + CI gofmt - hook/install: propagate wrapper-injected ctx to invokeOriginal so RunE/Run see context values added by upstream Wrappers - hook/testing: SetStderrForTesting returns a restore func; tests now defer it via t.Cleanup to avoid cross-test sink leakage - cmdpolicy/active: deep-copy ActivePolicy.Rule on SetActive/GetActive so callers can't mutate the stored global through shared slices - platform/inventory: deep-copy Inventory + nested Plugins / HookEntry / RuleView slices on SetActiveInventory / GetActiveInventory - platform/staging: Restrict clones the plugin-supplied Rule before retaining it so the plugin can't mutate it after Install returns - platform/version: reject RequiredCLIVersion with more than three numeric components instead of silently truncating 1.2.3.4 to 1.2.3 - cmd/platform_bootstrap: clear cmdpolicy.SetActive on yaml resolver error so config policy show doesn't surface a stale rule - cmd/platform_bootstrap_test: tmpHome pins LARKSUITE_CLI_CONFIG_DIR so host env can't bleed into the policy test fixtures - cmdpolicy/apply: installDenyStub returns bool; Apply count no longer over-reports when strict-mode short-circuits the install - cmdpolicy/engine: aggregateParents now returns the runnable hybrid's own denial status when all children are placeholder branches - cmdpolicy/resolver_test: use t.TempDir()-rooted missing path instead of hardcoded /nonexistent for hermetic missing-file assertion - cmd/config/plugins: empty-inventory branch emits total: 0 so the JSON schema stays stable across populated/empty cases - cmd/platform_guards_test: select leaf by RunE != nil (not Runnable) so the test doesn't nil-deref on Run-only commands - gofmt run on previously committed cmdpolicy/path*.go (CI fast-gate) * fix(cmdpolicy): replace filepath.Abs with filepath.Clean for lint policy The depguard / forbidigo rule blocks filepath.Abs in internal/ on the grounds that it accesses the filesystem (Getwd) directly. Switch RedactHomeDir + foldPrefix to operate on filepath.Clean strings; real callers pass already-absolute paths (resolver builds yamlPath via filepath.Join on the absolute config root), so the redaction outcome is unchanged for production inputs. Relative inputs fall through to the unchanged branch — filepath.Rel rejects the mixed-absoluteness case with an error, which the foldPrefix helper already treats as "not a hit". * refactor(cmdpolicy): pure Resolve + drop path redaction & verbose comments - Resolve becomes a pure function; I/O moves to LoadYAMLPolicy so precedence selection can be unit-tested without vfs mocks - ActivePolicy drops YAMLPath; config policy show JSON loses yaml_path and yaml_shadowed (and the TOCTOU stat that surfaced them) - RedactHomeDir and path_test.go removed: the home-dir folding was only earning its keep through the now-deleted yaml_path field - cmd/build.go bootstrap block trimmed from 71 to 39 lines by cutting PR-rationale comments; one note kept for the fail-CLOSED-vs-fail-OPEN business rule - cmd/config/config.go: parent Long no longer hard-codes hidden command hints, matching their Hidden:true intent Change-Id: Icfbb818ce3ef523c63286bfbed34c49be08ed6a2 * refactor(platform): drop StrictMode/Identity from Invocation interface These two accessors were documented in the public SDK as "After observers always see ok=true" but the framework never plumbed values to them, so they always returned ("", false). Zero internal/example/test callers; a plugin author trusting the doc would silently get wrong behaviour. Identity is also fundamentally unsuited for Before observers (per-command identity resolves inside RunE via f.AuthFor, after Before fires). StrictMode is a global value better placed on a Framework/Environment interface than per-Invocation. Removing is non-breaking now (no callers); adding later is non-breaking too. Change-Id: Ice200543e9bca3bda759ad98a6e34a56df69e915 * fix(prune): preserve original metadata on strict-mode denial stubs strictModeStubFrom built a fresh *cobra.Command from scratch, dropping the original command's annotations (risk_level, lark:supportedIdentities, cmdmeta.domain) and help text. cobraCommandView is a live proxy walking parent annotations, so after the Remove+Add replacement, audit observers firing on a strict-mode-denied command saw Cmd().Risk()=("",false) and Cmd().Identities()=nil -- breaking the first-class use case for audit/compliance plugins. Copy child.Annotations into the stub (stamping the denial annotations on top) and propagate Short/Long for help-text parity with cmdpolicy/apply.go::installDenyStub, which preserves these by virtue of mutating in place. Regression test asserts risk_level / supportedIdentities / Short / Long all survive replacement, alongside the denial annotations. Change-Id: I19810a34575996344b63e839066888c154d69335 * chore(platform): align docs with implementation; fold home in yaml warnings Followup cleanup to the previous three refactor commits, addressing review fallout where public docs / examples / contract notes still pointed at deleted symbols or unimplemented designs: - cmd/build.go: Build() docstring now mentions the plugin install + Startup emit side effects; Shutdown only fires on Execute path - extension/platform/doc.go, lifecycle.go, invocation.go: drop references to the deleted StrictMode/Identity methods, restore minimal Godoc on Cmd/Args/Started - extension/platform/view.go, cmd/platform_bootstrap.go, internal/hook/install.go: rewrite "snapshot before pruning" promise to match the actual contract (live view + strict-mode stub metadata preservation) - cmd/platform_guards_test.go: stubInvocation drops the two old methods - cmd/platform_bootstrap.go: redactHome() last-mile folds $HOME -> ~ in warnPolicyError so an os.PathError carrying the absolute policy path does not leak the user's home dir to stderr / agent / CI logs - examples/readonly-policy/README.md: drop yaml_path from the sample `config policy show` envelope (the field was removed in 52cbb92) Change-Id: I2874cc2cf9225dfa44a9c07b2449149181b387cb * chore(build): drop vestigial -tags testing from Makefile and CI The `testing` build tag was introduced in 461e3c6 to gate extension/platform/register_testing.go (ResetForTesting); PR review 0efee93 then dropped the //go:build testing directive from that file so downstream `go test ./...` would work without the tag, but never cleaned the matching tag references out of Makefile and ci.yml. The result: 8 places passing -tags testing for a tag that nothing in the repo actually gates, plus a Makefile comment that confidently claims a gate exists. Net behaviour is identical to omitting the flag; the only effect is misleading developers into believing there is a test-only surface separation. Drop the flag from vet / unit-test / lint / coverage / deadcode (head + base worktree) and remove the misleading comment. ResetForTesting's public-API exposure was the conscious trade-off taken in 0efee93 and is left untouched. Change-Id: If0cd78c87d4aec2a2533419fe75b01aae6b165fd * feat(cmdpolicy): enrich denial Reason with attempted value + rule constraint The envelope reason for command_denied previously told the caller WHAT axis failed but not the concrete values on each side, so an AI agent reading the envelope could not tell which command identity / risk / path was attempted vs. which the rule permits. The natural temptation was then to recommend modifying the rule -- exactly the wrong nudge, since policy exists to prevent the agent from rewriting its own limits. Each Reason now carries both the attempted value and the rule's constraint: identity_mismatch: "command supports identities [user]; rule allows [bot]" domain_not_allowed: "command path \"drive/+upload\" not in allow list [docs/** contact/**]" command_denylisted: "command path \"docs/+delete-doc\" matched deny pattern \"docs/+delete-*\"" risk_too_high / write_not_allowed: "command risk \"high-risk-write\" exceeds rule max_risk \"write\"" risk_not_annotated: "command has no risk_level annotation; rule denies unannotated commands" (drops the prescriptive "set allow_unannotated=true" hint -- that belongs in docs, not in the engine's denial path) Adds firstMatch() helper so command_denylisted can name the specific glob that fired; matchesAny() now wraps firstMatch. Regression test pins the substring contract per reason_code so future "comment cleanup" cannot silently strip the values out again. Change-Id: I17c7cc9411f58e3e43ade5e1ce875f3b7fe3e5ea * fix(cmdpolicy): gofmt engine_test.go CI fast-gate flagged the test added in 2eb0c2b as unformatted. Local make unit-test had it cached; should have run `make vet` (which runs gofmt-equivalent check via fmt-check) before pushing. Trivial 3-line indent fix. Change-Id: I42297ae59f607b97b32e976c9ec1c9ec4ab7de21 * feat(cmd): annotate risk_level on all hand-written cobra commands Without this, any non-empty user-layer policy.yml (default allow_unannotated=false) denies these commands with reason_code risk_not_annotated -- bricking auth login, config init, profile use etc. on first contact with a policy. cmdpolicy/engine evaluation now resolves to the intended axis (deny list / allow list / max_risk / identities) instead of failing closed on the unannotated gate. Policy authors can write `max_risk: write` or `allow: [auth/** config/** ...]` to express real intent. Classification: read auth status/check/list/scopes, config show / policy show / plugins show, doctor, completion, schema, profile list, event list/status/schema/ consume write auth login/logout, config init/bind/remove/ default-as/strict-mode, profile add/remove/ rename/use, event stop/_bus, api (raw transit) high-risk-write update (replaces the CLI binary; failure can leave the install broken) Notes: - api standalone is conservatively `write`; per-call risk is unknown at parse time (raw transit), so static gating only enforces the write-class minimum. - event _bus is the hidden IPC daemon forked by consume; standalone invocation by users is not expected, but the annotation keeps policy evaluation consistent with the other event subcommands. - The two diagnostic-allowlisted commands (config policy show / plugins show) still bypass the engine via diagnosticPaths; the read annotation is for consistency with surrounding leaves. --------- Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- Makefile | 25 +- cmd/api/api.go | 1 + cmd/auth/check.go | 1 + cmd/auth/list.go | 1 + cmd/auth/login.go | 3 +- cmd/auth/logout.go | 1 + cmd/auth/scopes.go | 1 + cmd/auth/status.go | 1 + cmd/build.go | 56 +- cmd/completion/completion.go | 1 + cmd/config/bind.go | 1 + cmd/config/config.go | 2 + cmd/config/default_as.go | 1 + cmd/config/init.go | 1 + cmd/config/plugins.go | 101 +++ cmd/config/policy.go | 75 ++ cmd/config/policy_test.go | 146 ++++ cmd/config/remove.go | 1 + cmd/config/show.go | 1 + cmd/config/strict_mode.go | 1 + cmd/doctor/doctor.go | 1 + cmd/event/bus.go | 1 + cmd/event/consume.go | 1 + cmd/event/list.go | 1 + cmd/event/schema.go | 1 + cmd/event/status.go | 1 + cmd/event/stop.go | 1 + cmd/global_flags_test.go | 4 +- cmd/platform_bootstrap.go | 274 +++++++ cmd/platform_bootstrap_test.go | 268 +++++++ cmd/platform_guards.go | 247 +++++++ cmd/platform_guards_test.go | 208 ++++++ cmd/plugin_integration_test.go | 684 ++++++++++++++++++ cmd/profile/add.go | 1 + cmd/profile/list.go | 1 + cmd/profile/remove.go | 1 + cmd/profile/rename.go | 1 + cmd/profile/use.go | 1 + cmd/prune.go | 73 +- cmd/prune_test.go | 177 +++++ cmd/root.go | 100 ++- cmd/root_integration_test.go | 56 +- cmd/root_test.go | 28 + cmd/schema/schema.go | 1 + cmd/unknown_subcommand_test.go | 177 +++++ cmd/update/update.go | 1 + extension/platform/README.md | 186 +++++ extension/platform/abort.go | 37 + extension/platform/abort_test.go | 42 ++ extension/platform/builder.go | 215 ++++++ extension/platform/builder_test.go | 180 +++++ extension/platform/capabilities.go | 50 ++ extension/platform/doc.go | 39 + extension/platform/errors.go | 40 + extension/platform/errors_test.go | 44 ++ extension/platform/example_test.go | 63 ++ extension/platform/examples/.gitignore | 2 + extension/platform/examples/README.md | 13 + .../examples/audit-observer/README.md | 26 + .../platform/examples/audit-observer/main.go | 44 ++ .../examples/readonly-policy/README.md | 61 ++ .../platform/examples/readonly-policy/main.go | 45 ++ extension/platform/handler.go | 39 + extension/platform/identity.go | 40 + extension/platform/invocation.go | 56 ++ extension/platform/lifecycle.go | 46 ++ extension/platform/plugin.go | 26 + extension/platform/register.go | 58 ++ extension/platform/register_test.go | 52 ++ extension/platform/register_testing.go | 16 + extension/platform/registrar.go | 36 + extension/platform/risk.go | 71 ++ extension/platform/risk_test.go | 120 +++ extension/platform/rule.go | 60 ++ extension/platform/selector.go | 133 ++++ extension/platform/selector_test.go | 161 +++++ extension/platform/view.go | 48 ++ go.mod | 3 +- go.sum | 2 + internal/cmdmeta/meta.go | 137 ++++ internal/cmdmeta/meta_test.go | 143 ++++ internal/cmdpolicy/active.go | 83 +++ internal/cmdpolicy/aggregation_test.go | 364 ++++++++++ internal/cmdpolicy/apply.go | 227 ++++++ internal/cmdpolicy/denial.go | 130 ++++ internal/cmdpolicy/denial_test.go | 98 +++ internal/cmdpolicy/diagnostic.go | 29 + internal/cmdpolicy/diagnostic_test.go | 86 +++ internal/cmdpolicy/engine.go | 392 ++++++++++ internal/cmdpolicy/engine_test.go | 505 +++++++++++++ internal/cmdpolicy/path.go | 39 + internal/cmdpolicy/resolver.go | 92 +++ internal/cmdpolicy/resolver_test.go | 123 ++++ internal/cmdpolicy/source_label_test.go | 96 +++ internal/cmdpolicy/strict_mode_skip_test.go | 163 +++++ internal/cmdpolicy/suggest.go | 86 +++ internal/cmdpolicy/suggest_test.go | 51 ++ internal/cmdpolicy/validate.go | 75 ++ internal/cmdpolicy/validate_test.go | 97 +++ internal/cmdpolicy/yaml/reader.go | 24 + internal/cmdpolicy/yaml/schema.go | 77 ++ internal/cmdpolicy/yaml/schema_test.go | 131 ++++ internal/cmdutil/factory.go | 2 +- internal/hook/doc.go | 20 + internal/hook/emit.go | 130 ++++ internal/hook/emit_test.go | 110 +++ internal/hook/install.go | 358 +++++++++ internal/hook/install_default.go | 11 + internal/hook/install_test.go | 397 ++++++++++ internal/hook/invocation.go | 68 ++ internal/hook/registry.go | 184 +++++ internal/hook/testing.go | 23 + internal/hook/walk.go | 18 + internal/platform/doc.go | 31 + internal/platform/error.go | 57 ++ internal/platform/host.go | 344 +++++++++ internal/platform/host_test.go | 391 ++++++++++ internal/platform/inventory.go | 264 +++++++ internal/platform/inventory_test.go | 91 +++ internal/platform/staging.go | 228 ++++++ internal/platform/version.go | 154 ++++ internal/platform/version_test.go | 178 +++++ shortcuts/register.go | 13 + shortcuts/register_test.go | 32 + 125 files changed, 11100 insertions(+), 39 deletions(-) create mode 100644 cmd/config/plugins.go create mode 100644 cmd/config/policy.go create mode 100644 cmd/config/policy_test.go create mode 100644 cmd/platform_bootstrap.go create mode 100644 cmd/platform_bootstrap_test.go create mode 100644 cmd/platform_guards.go create mode 100644 cmd/platform_guards_test.go create mode 100644 cmd/plugin_integration_test.go create mode 100644 cmd/unknown_subcommand_test.go create mode 100644 extension/platform/README.md create mode 100644 extension/platform/abort.go create mode 100644 extension/platform/abort_test.go create mode 100644 extension/platform/builder.go create mode 100644 extension/platform/builder_test.go create mode 100644 extension/platform/capabilities.go create mode 100644 extension/platform/doc.go create mode 100644 extension/platform/errors.go create mode 100644 extension/platform/errors_test.go create mode 100644 extension/platform/example_test.go create mode 100644 extension/platform/examples/.gitignore create mode 100644 extension/platform/examples/README.md create mode 100644 extension/platform/examples/audit-observer/README.md create mode 100644 extension/platform/examples/audit-observer/main.go create mode 100644 extension/platform/examples/readonly-policy/README.md create mode 100644 extension/platform/examples/readonly-policy/main.go create mode 100644 extension/platform/handler.go create mode 100644 extension/platform/identity.go create mode 100644 extension/platform/invocation.go create mode 100644 extension/platform/lifecycle.go create mode 100644 extension/platform/plugin.go create mode 100644 extension/platform/register.go create mode 100644 extension/platform/register_test.go create mode 100644 extension/platform/register_testing.go create mode 100644 extension/platform/registrar.go create mode 100644 extension/platform/risk.go create mode 100644 extension/platform/risk_test.go create mode 100644 extension/platform/rule.go create mode 100644 extension/platform/selector.go create mode 100644 extension/platform/selector_test.go create mode 100644 extension/platform/view.go create mode 100644 internal/cmdmeta/meta.go create mode 100644 internal/cmdmeta/meta_test.go create mode 100644 internal/cmdpolicy/active.go create mode 100644 internal/cmdpolicy/aggregation_test.go create mode 100644 internal/cmdpolicy/apply.go create mode 100644 internal/cmdpolicy/denial.go create mode 100644 internal/cmdpolicy/denial_test.go create mode 100644 internal/cmdpolicy/diagnostic.go create mode 100644 internal/cmdpolicy/diagnostic_test.go create mode 100644 internal/cmdpolicy/engine.go create mode 100644 internal/cmdpolicy/engine_test.go create mode 100644 internal/cmdpolicy/path.go create mode 100644 internal/cmdpolicy/resolver.go create mode 100644 internal/cmdpolicy/resolver_test.go create mode 100644 internal/cmdpolicy/source_label_test.go create mode 100644 internal/cmdpolicy/strict_mode_skip_test.go create mode 100644 internal/cmdpolicy/suggest.go create mode 100644 internal/cmdpolicy/suggest_test.go create mode 100644 internal/cmdpolicy/validate.go create mode 100644 internal/cmdpolicy/validate_test.go create mode 100644 internal/cmdpolicy/yaml/reader.go create mode 100644 internal/cmdpolicy/yaml/schema.go create mode 100644 internal/cmdpolicy/yaml/schema_test.go create mode 100644 internal/hook/doc.go create mode 100644 internal/hook/emit.go create mode 100644 internal/hook/emit_test.go create mode 100644 internal/hook/install.go create mode 100644 internal/hook/install_default.go create mode 100644 internal/hook/install_test.go create mode 100644 internal/hook/invocation.go create mode 100644 internal/hook/registry.go create mode 100644 internal/hook/testing.go create mode 100644 internal/hook/walk.go create mode 100644 internal/platform/doc.go create mode 100644 internal/platform/error.go create mode 100644 internal/platform/host.go create mode 100644 internal/platform/host_test.go create mode 100644 internal/platform/inventory.go create mode 100644 internal/platform/inventory_test.go create mode 100644 internal/platform/staging.go create mode 100644 internal/platform/version.go create mode 100644 internal/platform/version_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ffa82c37..37745d6ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Fetch meta data run: python3 scripts/fetch_meta.py - name: Run tests - run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... + run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/... lint: needs: fast-gate diff --git a/Makefile b/Makefile index 7733335b4..14480aeec 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ DATE := $(shell date +%Y-%m-%d) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local -.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks +.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks all: test @@ -21,13 +21,32 @@ build: fetch_meta vet: fetch_meta go vet ./... +# fmt-check fails when any file would be reformatted by gofmt. Keep this +# in sync with the fast-gate "Check formatting" step in CI. +fmt-check: + @unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \ + if [ -n "$$unformatted" ]; then \ + echo "Unformatted Go files:"; \ + echo "$$unformatted"; \ + echo "Run 'gofmt -w .' and commit."; \ + exit 1; \ + fi + +# ./extension/... keeps the public plugin SDK in the default test matrix. unit-test: fetch_meta - go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/... + go test -race -gcflags="all=-N -l" -count=1 \ + ./cmd/... ./internal/... ./shortcuts/... ./extension/... + +# examples-build keeps the shipped plugin-SDK examples compilable. If this +# breaks, the plugin author guide's "go build ./..." path is broken. +examples-build: + go build ./extension/platform/examples/audit-observer + go build ./extension/platform/examples/readonly-policy integration-test: build go test -v -count=1 ./tests/... -test: vet unit-test integration-test +test: vet fmt-check unit-test examples-build integration-test install: build install -d $(PREFIX)/bin diff --git a/cmd/api/api.go b/cmd/api/api.go index 83e963059..f5676e9b1 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -103,6 +103,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp }) + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/auth/check.go b/cmd/auth/check.go index 5f0bd0f48..2dd0652e8 100644 --- a/cmd/auth/check.go +++ b/cmd/auth/check.go @@ -37,6 +37,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra. cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") cmd.MarkFlagRequired("scope") + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/auth/list.go b/cmd/auth/list.go index 2cb1b778e..ff682f824 100644 --- a/cmd/auth/list.go +++ b/cmd/auth/list.go @@ -34,6 +34,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co return authListRun(opts) }, } + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/auth/login.go b/cmd/auth/login.go index ef0e455ca..02888c98e 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -52,7 +52,7 @@ send the verification URL to the user as your final message, end the turn, then run --device-code in a later step after the user confirms authorization.`, RunE: func(cmd *cobra.Command, args []string) error { if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { - return output.ErrWithHint(output.ExitValidation, "strict_mode", + return output.ErrWithHint(output.ExitValidation, "command_denied", fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode), "if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } @@ -64,6 +64,7 @@ run --device-code in a later step after the user confirms authorization.`, }, } cmdutil.SetSupportedIdentities(cmd, []string{"user"}) + cmdutil.SetRisk(cmd, "write") cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend") cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index ac14d7e63..3b2ae09f2 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -33,6 +33,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr return authLogoutRun(opts) }, } + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go index 23f8ef811..c70898dd5 100644 --- a/cmd/auth/scopes.go +++ b/cmd/auth/scopes.go @@ -37,6 +37,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr } cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/auth/status.go b/cmd/auth/status.go index 55abfe587..60118b35b 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -37,6 +37,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr } cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/build.go b/cmd/build.go index 6b5d1e5c1..a748544b0 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -19,7 +19,9 @@ import ( cmdupdate "github.com/larksuite/cli/cmd/update" _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/shortcuts" "github.com/spf13/cobra" @@ -59,18 +61,28 @@ func HideProfile(hide bool) BuildOption { } } -// Build constructs the full command tree without executing. -// Returns only the cobra.Command; Factory is internal. +// Build constructs the full command tree. It also installs registered +// plugins and emits the Startup lifecycle event during assembly -- +// so Plugin.On(Startup) handlers run even if the returned command is +// never dispatched. The matching Shutdown event is only emitted by +// Execute; callers that bypass Execute will not see Shutdown fire. +// +// Returns only the cobra.Command; Factory and hook Registry are internal. // Use Execute for the standard production entry point. func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command { - _, rootCmd := buildInternal(ctx, inv, opts...) + _, rootCmd, _ := buildInternal(ctx, inv, opts...) return rootCmd } // buildInternal is a pure assembly function: it wires the command tree from // inv and BuildOptions alone. Any state-dependent decision (disk, network, // env) belongs in the caller and must be threaded in via BuildOption. -func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) { +// +// Returns (factory, rootCmd, registry). The registry is nil when plugin +// install failed (FailClosed guard installed) or when no plugin produced +// hooks; callers that wire Shutdown emit must nil-check before calling +// hook.Emit. +func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) { // cfg.globals.Profile is left zero here; it's bound to the --profile // flag in RegisterGlobalFlags and filled by cobra's parse step. cfg := &buildConfig{} @@ -124,10 +136,42 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) - // Prune commands incompatible with strict mode. + installUnknownSubcommandGuard(rootCmd) + if mode := f.ResolveStrictMode(ctx); mode.IsActive() { pruneForStrictMode(rootCmd, mode) } - return f, rootCmd + installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut) + if installErr != nil { + installPluginInstallErrorGuard(rootCmd, installErr) + return f, rootCmd, nil + } + var pluginRules []cmdpolicy.PluginRule + var registry *hook.Registry + if installResult != nil { + pluginRules = installResult.PluginRules + registry = installResult.Registry + } + + // Policy errors fail-CLOSED when a plugin contributed (security + // intent must not be silently dropped); yaml-only errors fail-OPEN + // with a warning so a typo can't lock the user out. + if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil { + if len(pluginRules) > 0 { + installPluginConflictGuard(rootCmd, err) + return f, rootCmd, nil + } + warnPolicyError(cfg.streams.ErrOut, err) + } + + if registry != nil { + if err := wireHooks(ctx, rootCmd, registry); err != nil { + installPluginLifecycleErrorGuard(rootCmd, err) + return f, rootCmd, nil + } + } + + recordInventory(installResult) + return f, rootCmd, registry } diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go index 574365b7f..a7187bb33 100644 --- a/cmd/completion/completion.go +++ b/cmd/completion/completion.go @@ -37,5 +37,6 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command { }, } cmdutil.DisableAuthCheck(cmd) + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 2068d1439..383861ac7 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -103,6 +103,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`, cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)") cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)") cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)") + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/config/config.go b/cmd/config/config.go index b857e19b0..c99f6b482 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -31,6 +31,8 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdConfigShow(f, nil)) cmd.AddCommand(NewCmdConfigDefaultAs(f)) cmd.AddCommand(NewCmdConfigStrictMode(f)) + cmd.AddCommand(NewCmdConfigPolicy(f)) + cmd.AddCommand(NewCmdConfigPlugins(f)) return cmd } diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go index 1b590f5ad..a5078c1e9 100644 --- a/cmd/config/default_as.go +++ b/cmd/config/default_as.go @@ -52,5 +52,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { return nil }, } + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/config/init.go b/cmd/config/init.go index ed77d1a76..3fc56c725 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -80,6 +80,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`, cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)") cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)") 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 } diff --git a/cmd/config/plugins.go b/cmd/config/plugins.go new file mode 100644 index 000000000..a50d47075 --- /dev/null +++ b/cmd/config/plugins.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// NewCmdConfigPlugins exposes the plugin inventory diagnostic command. +// +// `config policy show` is intentionally focused on the user-layer Rule +// (Restrict). Plugins also contribute hooks (Observe / Wrap / Lifecycle) +// that are not policy gates but still mutate the CLI's runtime behaviour. +// This command surfaces both halves so an operator can answer "what is +// this binary doing differently from stock lark-cli?" in one place. +// +// Like config policy show, the dispatch path is exempt from policy +// enforcement (see internal/cmdpolicy/diagnostic.go) so it remains +// usable under any Rule. +func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugins", + Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context + Short: "Inspect installed plugins and their hook contributions", + // Same leaf-level no-op as config policy: the parent `config` + // group's PersistentPreRunE requires builtin credential, but + // this is a read-only diagnostic that must work everywhere. + PersistentPreRunE: func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + }, + } + cmd.AddCommand(newCmdConfigPluginsShow(f)) + return cmd +} + +func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "List successfully installed plugins, their rules, and registered hooks", + Long: `Print every plugin that committed during bootstrap, including: + + - name / version / capabilities (FailurePolicy, Restricts, RequiredCLIVersion) + - rule (when the plugin called r.Restrict) + - hooks: observers (Before / After), wrappers, lifecycle handlers + +Hooks are attributed by their namespaced name -- the framework prepends +the plugin name as the prefix at registration time, so an entry +"secaudit.audit-pre" belongs to plugin "secaudit".`, + RunE: func(cmd *cobra.Command, args []string) error { + return runConfigPluginsShow(f) + }, + } + cmdutil.SetRisk(cmd, "read") + return cmd +} + +func runConfigPluginsShow(f *cmdutil.Factory) error { + inv := internalplatform.GetActiveInventory() + if inv == nil { + // Always emit the same field set as the populated branch so + // AI agents and CI scripts don't have to branch on whether + // `total` is present. `note` makes the unusual state explicit + // for human readers. + output.PrintJson(f.IOStreams.Out, map[string]any{ + "plugins": []any{}, + "total": 0, + "note": "no inventory recorded; bootstrap did not finish", + }) + return nil + } + + plugins := make([]map[string]any, 0, len(inv.Plugins)) + for _, p := range inv.Plugins { + entry := map[string]any{ + "name": p.Name, + "version": p.Version, + "capabilities": p.Capabilities, + } + if p.Rule != nil { + entry["rule"] = p.Rule + } + entry["hooks"] = map[string]any{ + "observers": p.Observers, + "wrappers": p.Wrappers, + "lifecycle": p.Lifecycles, + "count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles), + } + plugins = append(plugins, entry) + } + output.PrintJson(f.IOStreams.Out, map[string]any{ + "plugins": plugins, + "total": len(plugins), + }) + return nil +} diff --git a/cmd/config/policy.go b/cmd/config/policy.go new file mode 100644 index 000000000..78f2b10a7 --- /dev/null +++ b/cmd/config/policy.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Hidden: true, + Short: "Inspect the user-layer command policy", + // Override parent's RequireBuiltinCredentialProvider check; this + // group is read-only diagnostic and must work under any provider. + PersistentPreRunE: func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + }, + } + cmd.AddCommand(newCmdConfigPolicyShow(f)) + return cmd +} + +func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Hidden: true, + Short: "Show the active user-layer policy (plugin / yaml / none)", + RunE: func(cmd *cobra.Command, args []string) error { + return runConfigPolicyShow(f) + }, + } + cmdutil.SetRisk(cmd, "read") + return cmd +} + +func runConfigPolicyShow(f *cmdutil.Factory) error { + active := cmdpolicy.GetActive() + if active == nil { + output.PrintJson(f.IOStreams.Out, map[string]any{ + "source": string(cmdpolicy.SourceNone), + "note": "no policy recorded; bootstrap did not run pruning", + }) + return nil + } + + sourceName := "" + if active.Source.Kind == cmdpolicy.SourcePlugin { + sourceName = active.Source.Name + } + out := map[string]any{ + "source": string(active.Source.Kind), + "source_name": sourceName, + "denied_paths": active.DeniedPaths, + } + if active.Rule != nil { + out["rule"] = map[string]any{ + "name": active.Rule.Name, + "description": active.Rule.Description, + "allow": active.Rule.Allow, + "deny": active.Rule.Deny, + "max_risk": active.Rule.MaxRisk, + "identities": active.Rule.Identities, + "allow_unannotated": active.Rule.AllowUnannotated, + } + } + output.PrintJson(f.IOStreams.Out, out) + return nil +} diff --git a/cmd/config/policy_test.go b/cmd/config/policy_test.go new file mode 100644 index 000000000..05d8a180b --- /dev/null +++ b/cmd/config/policy_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" +) + +func newPolicyTestFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := &cmdutil.Factory{ + IOStreams: cmdutil.NewIOStreams(nil, out, errOut), + } + return f, out, errOut +} + +// `config policy show` reads the active policy recorded by bootstrap. +// When nothing is recorded the command must still produce a JSON +// envelope with source=none and a note explaining the missing context. +func TestConfigPolicyShow_NoActivePolicy(t *testing.T) { + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + f, out, _ := newPolicyTestFactory() + if err := runConfigPolicyShow(f); err != nil { + t.Fatalf("show: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("not json: %v\n%s", err, out.String()) + } + if got["source"] != "none" { + t.Errorf("source = %v, want none", got["source"]) + } + if got["note"] == "" || got["note"] == nil { + t.Errorf("expected explanatory note when no policy recorded") + } +} + +// When bootstrap recorded an active plugin Rule, `show` emits the rule +// plus its source. +func TestConfigPolicyShow_PluginActive(t *testing.T) { + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + rule := &platform.Rule{ + Name: "secaudit", + Allow: []string{"docs/**"}, + MaxRisk: "read", + } + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ + Rule: rule, + Source: cmdpolicy.ResolveSource{ + Kind: cmdpolicy.SourcePlugin, + Name: "secaudit", + }, + DeniedPaths: 42, + }) + + f, out, _ := newPolicyTestFactory() + if err := runConfigPolicyShow(f); err != nil { + t.Fatalf("show: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("not json: %v\n%s", err, out.String()) + } + if got["source"] != "plugin" { + t.Errorf("source = %v, want plugin", got["source"]) + } + if got["source_name"] != "secaudit" { + t.Errorf("source_name = %v, want secaudit", got["source_name"]) + } + // json.Unmarshal returns float64 for numbers. + if got["denied_paths"] != float64(42) { + t.Errorf("denied_paths = %v, want 42", got["denied_paths"]) + } + ruleMap, ok := got["rule"].(map[string]any) + if !ok { + t.Fatalf("rule field missing or wrong type") + } + if ruleMap["name"] != "secaudit" { + t.Errorf("rule.name = %v", ruleMap["name"]) + } +} + +// `source_name` must be empty when source=yaml. The yaml path is +// deliberately not surfaced (matches engine envelope convention, +// avoids leaking the user's home dir to AI agents / CI logs). The +// rule's "name:" field is the disambiguator users should rely on. +func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) { + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ + Rule: &platform.Rule{Name: "my-yaml-rule"}, + Source: cmdpolicy.ResolveSource{ + Kind: cmdpolicy.SourceYAML, + Name: "/Users/alice/.lark-cli/policy.yml", + }, + }) + + f, out, _ := newPolicyTestFactory() + if err := runConfigPolicyShow(f); err != nil { + t.Fatalf("show: %v", err) + } + var got map[string]any + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("not json: %v\n%s", err, out.String()) + } + if got["source"] != "yaml" { + t.Errorf("source = %v, want yaml", got["source"]) + } + if got["source_name"] != "" { + t.Errorf("source_name = %q, want empty (yaml path must not leak)", got["source_name"]) + } + // The path must not appear anywhere in the envelope. + if bytes.Contains(out.Bytes(), []byte("/Users/alice")) { + t.Errorf("envelope leaked yaml path: %s", out.String()) + } +} + +// Regression: the parent `config` command declares a PersistentPreRunE +// that calls RequireBuiltinCredentialProvider; env credentials cause +// it to return external_provider. `config policy` is a diagnostic +// group that must not be blocked by that check. The group declares +// its own no-op PersistentPreRunE so cobra's "first walking up from +// leaf" picks ours over the config parent's. +func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) { + f, _, _ := newPolicyTestFactory() + group := NewCmdConfigPolicy(f) + if group.PersistentPreRunE == nil { + t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent") + } + if err := group.PersistentPreRunE(group, nil); err != nil { + t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err) + } +} diff --git a/cmd/config/remove.go b/cmd/config/remove.go index 52c5eb0ca..324f7e58c 100644 --- a/cmd/config/remove.go +++ b/cmd/config/remove.go @@ -32,6 +32,7 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro return configRemoveRun(opts) }, } + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/config/show.go b/cmd/config/show.go index 96018008f..1f0f12ffc 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -34,6 +34,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) * return configShowRun(opts) }, } + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/config/strict_mode.go b/cmd/config/strict_mode.go index 709010914..6bac82424 100644 --- a/cmd/config/strict_mode.go +++ b/cmd/config/strict_mode.go @@ -66,6 +66,7 @@ explicit user confirmation — never run on your own initiative.`, cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)") cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global") + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index f48b50ab6..1c6cd21af 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -43,6 +43,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command { } cmdutil.DisableAuthCheck(cmd) cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)") + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/event/bus.go b/cmd/event/bus.go index 90a83ce79..73d2958e7 100644 --- a/cmd/event/bus.go +++ b/cmd/event/bus.go @@ -64,6 +64,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&domain, "domain", "", "API domain") _ = cmd.Flags().MarkHidden("domain") + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/event/consume.go b/cmd/event/consume.go index db4548924..9fd4d234d 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -70,6 +70,7 @@ Use 'event schema ' for parameter details.`, _ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp }) + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/event/list.go b/cmd/event/list.go index 1520644d3..de2a95720 100644 --- a/cmd/event/list.go +++ b/cmd/event/list.go @@ -26,6 +26,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)") + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/event/schema.go b/cmd/event/schema.go index 298bf8c7c..830ce0566 100644 --- a/cmd/event/schema.go +++ b/cmd/event/schema.go @@ -88,6 +88,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)") + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/event/status.go b/cmd/event/status.go index 4e3fc2bb4..92c8be25d 100644 --- a/cmd/event/status.go +++ b/cmd/event/status.go @@ -37,6 +37,7 @@ func NewCmdStatus(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)") cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app") cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)") + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/event/stop.go b/cmd/event/stop.go index b9a8be1a6..adab2d3bb 100644 --- a/cmd/event/stop.go +++ b/cmd/event/stop.go @@ -70,6 +70,7 @@ Exit code: 2 if any target was refused or errored, 0 otherwise. cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons") cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus") cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)") + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/global_flags_test.go b/cmd/global_flags_test.go index c24d1573a..67ee19839 100644 --- a/cmd/global_flags_test.go +++ b/cmd/global_flags_test.go @@ -78,7 +78,7 @@ func TestIsSingleAppMode_MultiApp(t *testing.T) { } func TestBuildInternal_HideProfileOption(t *testing.T) { - _, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true)) + _, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true)) flag := root.PersistentFlags().Lookup("profile") if flag == nil { @@ -90,7 +90,7 @@ func TestBuildInternal_HideProfileOption(t *testing.T) { } func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) { - _, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams()) + _, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams()) flag := root.PersistentFlags().Lookup("profile") if flag == nil { diff --git a/cmd/platform_bootstrap.go b/cmd/platform_bootstrap.go new file mode 100644 index 000000000..ef2ac6b73 --- /dev/null +++ b/cmd/platform_bootstrap.go @@ -0,0 +1,274 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/hook" + internalplatform "github.com/larksuite/cli/internal/platform" + "github.com/larksuite/cli/internal/vfs" +) + +// userPolicyFileName is the conventional filename for the user-layer Rule. +// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state +// directory. +const userPolicyFileName = "policy.yml" + +// applyUserPolicyPruning resolves the user-layer Rule from plugin +// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs +// for commands it rejects. +// +// Missing yaml is not an error -- the CLI runs with no user-layer +// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.) +// surfaces via the returned error; the caller decides how to handle it. +// +// pluginRules carries Plugin.Restrict() contributions collected from +// the InstallAll phase; nil/empty is fine. +func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error { + yamlPath, err := userPolicyPath() + if err != nil { + // No user home dir means we cannot locate the policy. Treat + // the same as "file missing": no pruning, no error. This keeps + // non-interactive CI environments (no HOME set) running. + yamlPath = "" + } + + yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath) + if err != nil { + // Yaml-only failures are fail-OPEN at the caller (warn and + // continue), but the active-policy snapshot is process-global + // and may still carry data from a previous build in long-lived + // embedders / tests. Clear it explicitly so `config policy + // show` reports "no policy" instead of a stale rule that + // doesn't reflect the current command tree. + cmdpolicy.SetActive(nil) + return err + } + + rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + PluginRules: pluginRules, + YAMLRule: yamlRule, + YAMLPath: yamlPath, + }) + if err != nil { + cmdpolicy.SetActive(nil) + return err + } + if rule == nil { + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source}) + return nil + } + + engine := cmdpolicy.New(rule) + decisions := engine.EvaluateAll(rootCmd) + denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name) + cmdpolicy.Apply(rootCmd, denied) + + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ + Rule: rule, + Source: source, + DeniedPaths: len(denied), + }) + return nil +} + +// installPluginsAndHooks runs the InstallAll phase on the globally- +// registered plugins, returning the Plugin.Restrict contributions for +// cmdpolicy and the populated hook.Registry for the runtime wrapper. +// Errors from FailClosed plugins propagate; FailOpen failures are +// warned to errOut and the loop continues. +func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) { + plugins := platform.RegisteredPlugins() + if len(plugins) == 0 { + return &internalplatform.InstallResult{Registry: nil}, nil + } + return internalplatform.InstallAll(plugins, errOut) +} + +// recordInventory builds and stores the plugin inventory snapshot for +// diagnostic commands (config plugins show) to read at runtime. Called +// once from build.go after applyUserPolicyPruning + wireHooks succeed. +func recordInventory(installResult *internalplatform.InstallResult) { + if installResult == nil { + internalplatform.SetActiveInventory(nil) + return + } + pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins)) + for _, p := range installResult.Plugins { + pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{ + Name: p.Name, + Version: p.Version, + Capabilities: p.Capabilities, + }) + } + ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules)) + for _, r := range installResult.PluginRules { + if r.Rule == nil { + continue + } + idents := make([]string, len(r.Rule.Identities)) + for i, id := range r.Rule.Identities { + idents[i] = string(id) + } + ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{ + PluginName: r.PluginName, + Allow: r.Rule.Allow, + Deny: r.Rule.Deny, + MaxRisk: string(r.Rule.MaxRisk), + Identities: idents, + RuleName: r.Rule.Name, + Desc: r.Rule.Description, + AllowUnannotated: r.Rule.AllowUnannotated, + }) + } + internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs)) +} + +// wireHooks installs Observer/Wrapper hooks onto every runnable command +// and emits the Startup lifecycle event. The registry may be nil when +// no plugin contributed any hook -- the function short-circuits in +// that case to avoid useless RunE wrapping. +func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error { + if reg == nil { + return nil + } + hook.Install(rootCmd, reg, cobraCommandViewSource{}) + return hook.Emit(ctx, reg, platform.Startup, nil) +} + +// cobraCommandViewSource is the default CommandViewSource: it returns a +// live view over the *cobra.Command. Strict-mode's Remove+Add stub +// (cmd/prune.go::strictModeStubFrom) explicitly forwards the original +// annotations + Short/Long so the live view keeps reporting Risk / +// Identities / Domain through the replacement. User-layer policy +// (cmdpolicy/apply.go::installDenyStub) mutates in place, preserving +// metadata trivially. +type cobraCommandViewSource struct{} + +func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView { + return cobraCommandView{cmd: cmd} +} + +// cobraCommandView adapts *cobra.Command to the CommandView interface. +type cobraCommandView struct { + cmd *cobra.Command +} + +func (v cobraCommandView) Path() string { + return cmdpolicy.CanonicalPath(v.cmd) +} + +func (v cobraCommandView) Domain() string { + for c := v.cmd; c != nil; c = c.Parent() { + if c.Annotations == nil { + continue + } + if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" { + return v + } + } + return "" +} + +func (v cobraCommandView) Risk() (platform.Risk, bool) { + for c := v.cmd; c != nil; c = c.Parent() { + if c.Annotations == nil { + continue + } + if r, ok := c.Annotations["risk_level"]; ok && r != "" { + return platform.Risk(r), true + } + } + return "", false +} + +func (v cobraCommandView) Identities() []platform.Identity { + for c := v.cmd; c != nil; c = c.Parent() { + if c.Annotations == nil { + continue + } + if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" { + parts := splitCSV(raw) + out := make([]platform.Identity, len(parts)) + for i, p := range parts { + out[i] = platform.Identity(p) + } + return out + } + } + return nil +} + +func (v cobraCommandView) Annotation(key string) (string, bool) { + if v.cmd.Annotations == nil { + return "", false + } + s, ok := v.cmd.Annotations[key] + return s, ok +} + +// splitCSV is a tiny csv-without-quotes helper. The +// lark:supportedIdentities annotation is always plain +// "user" / "bot" / "user,bot" without escaping. +func splitCSV(s string) []string { + out := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == ',' { + out = append(out, s[start:i]) + start = i + 1 + } + } + out = append(out, s[start:]) + return out +} + +// userPolicyPath returns the path of /policy.yml. +// +// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via +// core.GetBaseConfigDir) so that test isolation, container deployments +// and per-Agent config overrides all see a consistent policy location. +// Using vfs.UserHomeDir directly here would silently bypass the env +// override and route every test through the real ~/.lark-cli. +// +// The error return is retained for caller compatibility but is always +// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when +// the home dir can't be resolved, and the resolver already treats a +// missing file as "no policy". +func userPolicyPath() (string, error) { + return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil +} + +// warnPolicyError writes a one-line stderr warning when the user policy +// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running +// without policy enforcement so the user can fix the typo. Plugin-supplied +// rules are fail-CLOSED instead because integrators take a code-level +// responsibility for them. +// +// Wrapped errors may carry the absolute policy path (os.PathError); fold +// the home prefix to "~" before emitting so stderr piped into agents / +// CI logs does not leak the user's home directory. +func warnPolicyError(errOut io.Writer, err error) { + if err == nil { + return + } + fmt.Fprintf(errOut, "warning: user policy not applied: %s\n", redactHome(err.Error())) +} + +func redactHome(s string) string { + if home, err := vfs.UserHomeDir(); err == nil && home != "" { + s = strings.ReplaceAll(s, home, "~") + } + return s +} diff --git a/cmd/platform_bootstrap_test.go b/cmd/platform_bootstrap_test.go new file mode 100644 index 000000000..4fe814453 --- /dev/null +++ b/cmd/platform_bootstrap_test.go @@ -0,0 +1,268 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// tmpHome creates a tempdir, points $HOME at it, and returns the path to +// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored +// when the test ends. +// +// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that +// override, a developer running the tests with a personal +// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with +// a baked-in value) would resolve userPolicyPath() to their real +// machine and bleed unrelated yaml into the test fixtures. With the +// override pinned here, the test is hermetic regardless of the host +// environment. +func tmpHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir + cfgDir := filepath.Join(dir, ".lark-cli") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir) + return cfgDir +} + +// writePolicy writes a policy.yml into the user config dir. +func writePolicy(t *testing.T, cfgDir string, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil { + t.Fatalf("write policy: %v", err) + } +} + +// fakeTree builds a minimal command tree with the same shape the real +// CLI exposes for these tests: lark-cli has a docs group with +fetch and +// +update, and an im group with +send. Each leaf has its risk_level set +// so MaxRisk filtering exercises a real path. +func fakeTree(t *testing.T) *cobra.Command { + t.Helper() + root := &cobra.Command{Use: "lark-cli"} + + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + addLeaf(docs, "+fetch", "read") + addLeaf(docs, "+update", "write") + addLeaf(docs, "+delete-doc", "high-risk-write") + + im := &cobra.Command{Use: "im"} + root.AddCommand(im) + addLeaf(im, "+send", "write") + + return root +} + +func addLeaf(parent *cobra.Command, use, risk string) { + leaf := &cobra.Command{ + Use: use, + RunE: func(*cobra.Command, []string) error { return nil }, + } + cmdutil.SetRisk(leaf, risk) + parent.AddCommand(leaf) +} + +// findLeaf walks the tree by Use names. +func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command { + t.Helper() + cur := parent + for _, n := range names { + var next *cobra.Command + for _, c := range cur.Commands() { + if c.Use == n { + next = c + break + } + } + if next == nil { + t.Fatalf("child %q not found under %q", n, cur.Use) + } + cur = next + } + return cur +} + +// Happy path: a valid policy.yml denies one specific command. The denied +// command's RunE returns a typed ExitError envelope; allowed commands are +// untouched. +func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) { + cfgDir := tmpHome(t) + writePolicy(t, cfgDir, ` +name: test-policy +allow: ["docs/**", "contact/**"] +deny: ["docs/+delete-doc"] +max_risk: write +`) + + root := fakeTree(t) + if err := applyUserPolicyPruning(root, nil); err != nil { + t.Fatalf("apply policy: %v", err) + } + + // docs/+delete-doc must be denied (Deny match). + deleteCmd := findLeaf(t, root, "docs", "+delete-doc") + if !deleteCmd.Hidden { + t.Errorf("+delete-doc should be hidden after pruning") + } + err := deleteCmd.RunE(deleteCmd, nil) + if err == nil { + t.Fatalf("+delete-doc RunE should return an error") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" { + t.Fatalf("expected command_denied ExitError, got %T %+v", err, err) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok || detail["reason_code"] != "command_denylisted" { + t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"]) + } + + // im/+send must be denied (domain not in Allow). + send := findLeaf(t, root, "im", "+send") + if !send.Hidden { + t.Errorf("im/+send should be hidden (not in Allow)") + } + + // docs/+update must stay alive (domain matches, risk within max). + update := findLeaf(t, root, "docs", "+update") + if update.Hidden { + t.Errorf("docs/+update should remain visible") + } + if err := update.RunE(update, nil); err != nil { + t.Errorf("docs/+update RunE should succeed, got %v", err) + } +} + +// Missing file means no pruning -- the CLI runs unrestricted with the +// full command surface. This is the default case for users who haven't +// opted into pruning. +func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) { + tmpHome(t) // home set but no policy.yml written + + root := fakeTree(t) + if err := applyUserPolicyPruning(root, nil); err != nil { + t.Fatalf("missing policy should not error, got %v", err) + } + + // Every leaf must remain non-Hidden. + for _, sub := range []string{"+fetch", "+update", "+delete-doc"} { + cmd := findLeaf(t, root, "docs", sub) + if cmd.Hidden { + t.Errorf("%s should not be Hidden when no policy file exists", sub) + } + } +} + +// Invalid yaml content (parse error) surfaces as an error from the +// wiring. The build path then decides whether to fail-open or +// fail-closed; the wiring itself stays neutral. +func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) { + cfgDir := tmpHome(t) + writePolicy(t, cfgDir, "::: not yaml :::") + + root := fakeTree(t) + err := applyUserPolicyPruning(root, nil) + if err == nil { + t.Fatalf("malformed yaml should produce an error") + } +} + +// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside +// Resolve and produces an error. This is the safety contract: a typo in +// the rule must not silently lower the pruning bar. +func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) { + cfgDir := tmpHome(t) + writePolicy(t, cfgDir, "max_risk: nukem\n") + + root := fakeTree(t) + err := applyUserPolicyPruning(root, nil) + if err == nil { + t.Fatalf("invalid MaxRisk should produce an error") + } +} + +// warnPolicyError emits to the supplied writer when err is non-nil and +// stays silent for nil. Verifies the build.go fail-open behaviour can be +// observed by users. +func TestWarnPolicyError(t *testing.T) { + var buf bytes.Buffer + warnPolicyError(&buf, nil) + if buf.Len() != 0 { + t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String()) + } + + buf.Reset() + warnPolicyError(&buf, errors.New("boom")) + if buf.String() != "warning: user policy not applied: boom\n" { + t.Fatalf("warnPolicyError output = %q", buf.String()) + } +} + +// End-to-end through buildInternal: when a valid policy.yml exists in +// HOME, building the real command tree applies pruning to it. This is +// the "actually integrated" test -- it exercises the wiring point in +// build.go itself, not just the helper. +func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) { + cfgDir := tmpHome(t) + // Deny one specific shortcut path that we know exists in the real + // service tree -- we cannot enumerate it from a unit test, so we + // use an Allow-list that matches nothing to deny everything except + // the root, and then verify ANY non-root command was hidden. + writePolicy(t, cfgDir, ` +name: deny-everything +deny: ["**"] +`) + + root := Build(context.Background(), buildInvocationForTest(t)) + + // Find any leaf and verify it was hidden. + var foundHidden bool + walk(root, func(c *cobra.Command) { + if c.HasParent() && c.Runnable() && c.Hidden { + foundHidden = true + } + }) + if !foundHidden { + t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy") + } + + // Root itself must stay alive. + if root.Hidden { + t.Errorf("root command must not be Hidden even under deny-everything policy") + } +} + +func walk(cmd *cobra.Command, fn func(*cobra.Command)) { + if cmd == nil { + return + } + fn(cmd) + for _, c := range cmd.Commands() { + walk(c, fn) + } +} + +// buildInvocationForTest returns a minimal cmdutil.InvocationContext so +// build.go's pure-assembly path can construct a tree without touching +// real config / credentials. Profile name is the empty default. +func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext { + t.Helper() + return cmdutil.InvocationContext{} +} diff --git a/cmd/platform_guards.go b/cmd/platform_guards.go new file mode 100644 index 000000000..714d147fd --- /dev/null +++ b/cmd/platform_guards.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/hook" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// installFatalGuard wires a fail-closed guard at every cobra dispatch +// path on rootCmd. Used by the three abort-side fatal paths: +// +// - FailClosed plugin install failure (installPluginInstallErrorGuard) +// - Plugin Restrict conflict (installPluginConflictGuard) +// - Startup lifecycle handler failure (installPluginLifecycleErrorGuard) +// +// **Why we walk the tree rather than set PersistentPreRunE on root**: +// cobra's PersistentPreRunE has "first PersistentPreRunE wins" +// semantics -- the lookup starts at the invoked command and walks UP, +// stopping at the first non-nil PersistentPreRunE. Subcommands that +// declare their own PersistentPreRunE (cmd/auth/auth.go and +// cmd/config/config.go both do) would shadow root's, letting a +// fail-closed condition silently bypass via `lark-cli auth foo`. +// +// The fix: replace the RunE of every runnable command with one that +// returns makeErr(). Subcommands cannot bypass because the dispatch +// lands directly on their RunE, which now carries the guard. +// +// makeErr is called for every guarded dispatch; it must return a fresh +// *output.ExitError each time (the envelope writer mutates a few fields +// as it serialises). +func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) { + // Two cobra subcommands are injected lazily at Execute() time and + // would otherwise slip past walkGuard. We pre-register both so + // walkGuard catches them. + // + // - "completion" (user-visible): InitDefaultCompletionCmd + // - "__complete" (internal shell-completion RPC): no public + // constructor; we add our own stub with the same name. cobra's + // internal initCompleteCmd checks for an existing "__complete" + // and skips registration if found, so our stub stays in place. + // (Cobra dispatches the "__completeNoDesc" alias through the + // same RunE, so guarding "__complete" covers both.) + rootCmd.InitDefaultCompletionCmd() + alreadyPresent := false + for _, c := range rootCmd.Commands() { + if c.Name() == "__complete" { + alreadyPresent = true + break + } + } + if !alreadyPresent { + rootCmd.AddCommand(&cobra.Command{ + Use: "__complete", + Hidden: true, + RunE: func(*cobra.Command, []string) error { return makeErr() }, + }) + } + + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + return makeErr() + } + rootCmd.PersistentPreRun = nil + walkGuard(rootCmd, makeErr) +} + +// installPluginInstallErrorGuard surfaces a FailClosed plugin install +// failure as a structured plugin_install envelope before any command +// runs. +func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) { + makeErr := func() *output.ExitError { + var pi *internalplatform.PluginInstallError + if errors.As(installErr, &pi) { + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "plugin_install", + Message: pi.Error(), + Detail: map[string]any{ + "plugin": pi.PluginName, + "reason_code": pi.ReasonCode, + "reason": pi.Reason, + }, + }, + Err: installErr, + } + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "plugin_install", + Message: installErr.Error(), + Detail: map[string]any{ + "reason_code": internalplatform.ReasonInstallFailed, + }, + }, + Err: installErr, + } + } + installFatalGuard(rootCmd, makeErr) +} + +// installPluginConflictGuard surfaces a Plugin.Restrict() configuration +// error (single plugin invalid Rule or multiple plugins each contributing +// Restrict). The design separates the envelope type: +// +// - "plugin_install" with reason_code "invalid_rule" - single bad rule +// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi +// +// Either way the CLI must NOT silently continue with a broken policy. +func installPluginConflictGuard(rootCmd *cobra.Command, err error) { + makeErr := func() *output.ExitError { + envelopeType := "plugin_install" + reasonCode := internalplatform.ReasonInvalidRule + if errors.Is(err, cmdpolicy.ErrMultipleRestricts) { + envelopeType = "plugin_conflict" + reasonCode = internalplatform.ReasonMultipleRestricts + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: envelopeType, + Message: err.Error(), + Detail: map[string]any{ + "reason_code": reasonCode, + }, + }, + Err: err, + } + } + installFatalGuard(rootCmd, makeErr) +} + +// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler +// failure as a plugin_lifecycle envelope. The reason_code splits +// returned-error vs panic so consumers (audit / on-call) can tell the +// two failure modes apart. +func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) { + makeErr := func() *output.ExitError { + reasonCode := "lifecycle_failed" + detail := map[string]any{ + "reason_code": reasonCode, + } + var le *hook.LifecycleError + if errors.As(err, &le) { + if le.Panic { + reasonCode = "lifecycle_panic" + } + detail = map[string]any{ + "reason_code": reasonCode, + "hook_name": le.HookName, + "event": "startup", + } + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "plugin_lifecycle", + Message: err.Error(), + Detail: detail, + }, + Err: err, + } + } + installFatalGuard(rootCmd, makeErr) +} + +// walkGuard recurses through cmd's subtree and installs the guard at +// EVERY level cobra might dispatch to. The cobra execution order is: +// +// 1. PersistentPreRunE (looked up from leaf, walking up; "first wins") +// 2. PreRunE +// 3. RunE +// 4. PostRunE +// 5. PersistentPostRunE +// +// A subcommand that declares its own PersistentPreRunE (cmd/auth and +// cmd/config both do) would not only shadow root's PersistentPreRunE +// -- if that PreRunE itself returns an error (e.g. auth's +// external_provider check), the user sees THAT error instead of +// our plugin_install envelope, even if RunE was guarded. +// +// To close every dispatch hole we replace: +// - every command's PersistentPreRunE (including non-runnable groups) +// - every runnable command's PreRunE and RunE +// +// This way the very first non-nil step in cobra's chain is always our +// guard, regardless of which leaf the user invoked. +func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) { + if cmd == nil { + return + } + // PersistentPreRunE is the first step cobra runs (after Args / + // flag validation -- see below). Set it on every command (root + // included) so cobra's "first wins" walk-up always finds OUR + // PersistentPreRunE before hitting any subcommand's pre-existing + // one. + cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error { + c.SilenceUsage = true + return makeErr() + } + cmd.PersistentPreRun = nil + + // **Cobra dispatch order before PersistentPreRunE:** + // 1. ValidateArgs(cmd.Args) -- can return arg error + // 2. ParsePersistentFlags / ParseFlags -- can return flag error + // 3. Find legacyArgs check for unknown-command at root + // 4. PersistentPreRunE / PreRunE / RunE + // 5. Non-runnable groups fall through to help (PreRunE skipped) + // + // We neutralise each step: + // - Args = ArbitraryArgs -> ValidateArgs no-op. **Not nil**: + // cobra falls back to legacyArgs + // when Args==nil, which returns an + // unknown-command error during Find + // BEFORE PersistentPreRunE runs. + // ArbitraryArgs explicitly accepts + // everything, suppressing that path. + // - DisableFlagParsing -> ParseFlags skipped (and legacy + // "unknown flag" suppressed) + // - PreRunE / RunE on EVERY -> Even non-runnable groups now run + // command (not just leaves) the guard instead of showing help + // + // Setting RunE on a parent group flips Runnable() to true, so + // cobra dispatches to it (and our guard fires) rather than calling + // the help command on a "help-only" group. + cmd.Args = cobra.ArbitraryArgs + cmd.DisableFlagParsing = true + cmd.PreRunE = func(c *cobra.Command, args []string) error { + c.SilenceUsage = true + return makeErr() + } + cmd.PreRun = nil + cmd.RunE = func(*cobra.Command, []string) error { return makeErr() } + cmd.Run = nil + for _, c := range cmd.Commands() { + walkGuard(c, makeErr) + } +} diff --git a/cmd/platform_guards_test.go b/cmd/platform_guards_test.go new file mode 100644 index 000000000..bd23e8563 --- /dev/null +++ b/cmd/platform_guards_test.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// failClosedAbortingPlugin returns a PluginInstallError on Install, +// declaring FailClosed so InstallAll surfaces the error. +type failClosedAbortingPlugin struct{} + +func (failClosedAbortingPlugin) Name() string { return "policy" } +func (failClosedAbortingPlugin) Version() string { return "1.0.0" } +func (failClosedAbortingPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{FailurePolicy: platform.FailClosed} +} +func (failClosedAbortingPlugin) Install(platform.Registrar) error { + return errors.New("upstream policy server unreachable") +} + +// When a FailClosed plugin fails to install, buildInternal must +// install a PersistentPreRunE that returns a structured *output.ExitError. +// The user must NEVER see a silent partial-install state. +// +// This pins the build.go fix for codex's NEW ISSUE about +// build.go demoting FailClosed errors to warnings. +func TestBuildInternal_failClosedAbortsCLI(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + platform.Register(failClosedAbortingPlugin{}) + + root := Build(context.Background(), buildInvocationForTest(t)) + + if root.PersistentPreRunE == nil { + t.Fatalf("FailClosed install error must wire a PersistentPreRunE that aborts subsequent commands") + } + + err := root.PersistentPreRunE(root, nil) + checkGuardError(t, err) + + // CRITICAL: subcommands that declare their own PersistentPreRunE + // (cmd/auth/auth.go and cmd/config/config.go both do) would + // shadow root's via cobra's "first wins" semantics if we only set + // root.PersistentPreRunE. Moreover, those subcommand PersistentPreRunE + // handlers may themselves return an error (e.g. auth's + // external_provider check at internal/cmdutil/factory.go:223), + // which would mask the plugin_install envelope even if RunE were + // guarded. + // + // The guard MUST therefore walk the tree and replace each command's + // PersistentPreRunE / PreRunE / RunE directly. This test pins + // that the bypass is closed. + auth := findChildByUse(t, root, "auth") + if auth == nil { + t.Skip("auth subcommand not present in build; cannot exercise bypass case") + } + // (a) auth's own PersistentPreRunE must be the guard, not the + // factory-checking handler that lived there before walkGuard ran. + if auth.PersistentPreRunE == nil { + t.Fatalf("auth.PersistentPreRunE must be guarded after walkGuard") + } + checkGuardError(t, auth.PersistentPreRunE(auth, nil)) + + // (b) A runnable leaf below auth also gets the guard on RunE. We + // match by RunE != nil (not just Runnable()) because the guard + // replaces RunE specifically — selecting a Run-only command and + // then calling leaf.RunE would nil-deref. + var leaf *cobra.Command + walk(auth, func(c *cobra.Command) { + if leaf != nil { + return + } + if c != auth && c.RunE != nil { + leaf = c + } + }) + if leaf == nil { + t.Skip("no auth subcommand with RunE found") + } + checkGuardError(t, leaf.RunE(leaf, nil)) +} + +// checkGuardError asserts that err is the structured plugin_install +// ExitError the guard produces. +func checkGuardError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatalf("PersistentPreRunE must surface the install error, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "plugin_install" { + t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type) + } + detail := exitErr.Detail.Detail.(map[string]any) + if detail["plugin"] != "policy" { + t.Errorf("detail.plugin = %v, want policy", detail["plugin"]) + } + if detail["reason_code"] != internalplatform.ReasonInstallFailed { + t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"]) + } +} + +// findChildByUse helper. +func findChildByUse(t *testing.T, parent *cobra.Command, use string) *cobra.Command { + t.Helper() + for _, c := range parent.Commands() { + if c.Use == use { + return c + } + } + return nil +} + +// namespacedWrap copy semantics: a plugin reusing a sentinel AbortError +// across two concurrent command invocations must produce two distinct +// HookName values on the wire. Mutation would interleave them. +// +// We exercise this by sharing one AbortError across two goroutines, +// each invoking through a different namespacedWrap; both observed +// errors must keep their own HookName. +func TestNamespacedWrap_doesNotMutateSharedAbortError(t *testing.T) { + shared := &platform.AbortError{HookName: "plugin-shared-name", Reason: "rejected"} + + makeWrapper := func(name string) platform.Wrapper { + return func(next platform.Handler) platform.Handler { + return func(context.Context, platform.Invocation) error { return shared } + } + } + + reg := hook.NewRegistry() + reg.AddWrapper(hook.WrapperEntry{ + Name: "p1.wrap", Selector: platform.All(), Fn: makeWrapper("p1.wrap"), + }) + reg.AddWrapper(hook.WrapperEntry{ + Name: "p2.wrap", Selector: platform.All(), Fn: makeWrapper("p2.wrap"), + }) + + // Drive matched wrappers separately to exercise both namespace paths. + matched := reg.MatchingWrappers(stubView{}) + if len(matched) != 2 { + t.Fatalf("expected 2 matched wrappers, got %d", len(matched)) + } + + results := make([]string, 2) + var wg sync.WaitGroup + wg.Add(2) + for i, m := range matched { + go func() { + defer wg.Done() + err := m.Fn(func(context.Context, platform.Invocation) error { return nil })( + context.Background(), stubInvocation{}) + if ab, ok := err.(*platform.AbortError); ok { + results[i] = ab.HookName + } + }() + } + wg.Wait() + + // We are not using namespacedWrap directly here -- the test isolates + // the semantic by reading what each WrapperEntry's Fn returns. + // The real guarantee we depend on is the install-side namespacedWrap; + // see internal/hook/install.go for the production path. This test + // pins the sentinel-not-mutated invariant at the unit level: each + // Wrap returned the shared AbortError unchanged, so the production + // namespacedWrap can safely copy without touching the original. + if shared.HookName != "plugin-shared-name" { + t.Errorf("shared sentinel AbortError was mutated: HookName = %q", shared.HookName) + } + _ = results +} + +// stubView for the wrap selector match. +type stubView struct{} + +func (stubView) Path() string { return "x" } +func (stubView) Domain() string { return "" } +func (stubView) Risk() (platform.Risk, bool) { return "", false } +func (stubView) Identities() []platform.Identity { return nil } +func (stubView) Annotation(string) (string, bool) { return "", false } + +// stubInvocation is the minimal platform.Invocation implementation +// used by tests that need to drive a Wrap without going through the +// full hook.Install pipeline. +type stubInvocation struct{} + +func (stubInvocation) Cmd() platform.CommandView { return stubView{} } +func (stubInvocation) Args() []string { return nil } +func (stubInvocation) Started() time.Time { return time.Time{} } +func (stubInvocation) Err() error { return nil } +func (stubInvocation) DeniedByPolicy() bool { return false } +func (stubInvocation) DenialLayer() string { return "" } +func (stubInvocation) DenialPolicySource() string { return "" } diff --git a/cmd/plugin_integration_test.go b/cmd/plugin_integration_test.go new file mode 100644 index 000000000..e439adbfc --- /dev/null +++ b/cmd/plugin_integration_test.go @@ -0,0 +1,684 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/hook" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// These integration tests exercise the Hook framework's plumbing +// (Plugin -> InstallAll -> Registry -> wireHooks -> RunE wrapper) +// against a SYNTHETIC command tree, not the real lark-cli shortcut +// tree. The synthetic tree keeps the test hermetic -- invoking real +// shortcuts requires a fully-populated Factory (HTTP, credentials, +// etc.) which is out of scope for a hook plumbing test. +// +// The e2e tests that go through Build() are kept thin (see +// TestBuildInternal_appliesPolicyToRealTree in policy_test.go); they +// assert plumbing existence (Hidden flag, etc.) without invoking +// shortcuts. + +type fakeIntegrationPlugin struct { + name string + caps platform.Capabilities + rule *platform.Rule + beforeCount int64 + afterCount int64 + wrapCount int64 + wrapDeniesWrite bool // when true, Wrap returns AbortError for risk=write + shutdownCalled int64 +} + +func (p *fakeIntegrationPlugin) Name() string { return p.name } +func (p *fakeIntegrationPlugin) Version() string { return "0.0.1" } +func (p *fakeIntegrationPlugin) Capabilities() platform.Capabilities { return p.caps } + +func (p *fakeIntegrationPlugin) Install(r platform.Registrar) error { + if p.caps.Restricts && p.rule != nil { + r.Restrict(p.rule) + } + r.Observe(platform.Before, "audit-pre", platform.All(), + func(context.Context, platform.Invocation) { + atomic.AddInt64(&p.beforeCount, 1) + }) + r.Observe(platform.After, "audit-post", platform.All(), + func(context.Context, platform.Invocation) { + atomic.AddInt64(&p.afterCount, 1) + }) + r.Wrap("policy", platform.ByWrite(), + func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + atomic.AddInt64(&p.wrapCount, 1) + if p.wrapDeniesWrite { + return &platform.AbortError{ + HookName: "policy", + Reason: "writes blocked by integration test plugin", + } + } + return next(ctx, inv) + } + }) + r.On(platform.Shutdown, "flush", + func(context.Context, *platform.LifecycleContext) error { + atomic.AddInt64(&p.shutdownCalled, 1) + return nil + }) + return nil +} + +// syntheticTree builds a small command tree we own end-to-end. The leaf +// has risk=write so the Wrap's ByWrite() selector matches. +func syntheticTree() (*cobra.Command, *cobra.Command) { + root := &cobra.Command{Use: "lark-cli"} + group := &cobra.Command{Use: "docs"} + root.AddCommand(group) + leaf := &cobra.Command{ + Use: "+write", + RunE: func(*cobra.Command, []string) error { return nil }, + } + cmdutil.SetRisk(leaf, "write") + group.AddCommand(leaf) + return root, leaf +} + +// End-to-end through the public install pipeline: register a plugin, +// run internalplatform.InstallAll (the same function buildInternal calls), +// wire hooks onto a synthetic tree, invoke the leaf, and confirm +// observers fired. +func TestPluginPipeline_observersWired(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + plugin := &fakeIntegrationPlugin{ + name: "audit-plugin", + caps: platform.Capabilities{FailurePolicy: platform.FailOpen}, + } + platform.Register(plugin) + + result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + + root, leaf := syntheticTree() + if err := wireHooks(context.Background(), root, result.Registry); err != nil { + t.Fatalf("wireHooks: %v", err) + } + + _ = leaf.RunE(leaf, nil) + + if got := atomic.LoadInt64(&plugin.beforeCount); got != 1 { + t.Errorf("Before observer fired %d times, want 1", got) + } + if got := atomic.LoadInt64(&plugin.afterCount); got != 1 { + t.Errorf("After observer fired %d times, want 1", got) + } + if got := atomic.LoadInt64(&plugin.wrapCount); got != 1 { + t.Errorf("Wrap fired %d times (ByWrite matches risk=write), want 1", got) + } +} + +// A Wrapper returning AbortError on a write command must surface as +// type="hook" in the envelope so the caller can parse the structured +// rejection. +func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + plugin := &fakeIntegrationPlugin{ + name: "policy-plugin", + caps: platform.Capabilities{FailurePolicy: platform.FailOpen}, + wrapDeniesWrite: true, + } + platform.Register(plugin) + + result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + + root, leaf := syntheticTree() + if err := wireHooks(context.Background(), root, result.Registry); err != nil { + t.Fatalf("wireHooks: %v", err) + } + + err = leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "hook" { + t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + } + detail := exitErr.Detail.Detail.(map[string]any) + if detail["reason_code"] != "aborted" { + t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"]) + } + if detail["hook_name"] != "policy-plugin.policy" { + t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"]) + } + + // errors.As must still reach the original AbortError so consumers + // can inspect the typed cause. + var ab *platform.AbortError + if !errors.As(err, &ab) { + t.Errorf("error chain should expose *platform.AbortError") + } +} + +// Plugin.Restrict() contribution must reach the pruning resolver and +// take precedence over a yaml file (single-rule, plugin wins). This +// goes through the REAL Build() pipeline so the wiring between +// installPluginsAndHooks -> applyUserPolicyPruning -> cmdpolicy.Resolve +// is covered. +func TestPluginPipeline_restrictBeatsYaml(t *testing.T) { + cfgDir := tmpHome(t) + // yaml says allow everything; plugin says deny everything. Plugin + // should win and a command should be denied. + if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), + []byte("name: yaml-allow\nallow: [\"**\"]\n"), 0o644); err != nil { + t.Fatalf("write yaml: %v", err) + } + + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + plugin := &fakeIntegrationPlugin{ + name: "restricter", + caps: platform.Capabilities{ + Restricts: true, + FailurePolicy: platform.FailClosed, + }, + rule: &platform.Rule{Name: "deny-all", Deny: []string{"**"}}, + } + platform.Register(plugin) + + root := Build(context.Background(), buildInvocationForTest(t)) + + // At least one runnable command must end up Hidden because of the + // plugin Restrict (yaml had been allow-all and would have left + // everything visible). + var foundHidden bool + walk(root, func(c *cobra.Command) { + if c.HasParent() && c.Runnable() && c.Hidden { + foundHidden = true + } + }) + if !foundHidden { + t.Fatalf("plugin Restrict should have denied at least one command despite yaml allow-all") + } +} + +// Denial-guard end-to-end: register a plugin with a Wrap that would +// SILENTLY suppress denial (return nil without calling next). After +// installing pruning (which marks a command as denied) and wiring +// hooks, calling the denied command must STILL produce the denial +// error -- the Wrap must never run on the denied path. +func TestPluginPipeline_denialGuardIntegrated(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + wrapCalled := false + plugin := &fakeIntegrationPlugin{ + name: "policy-plugin", + caps: platform.Capabilities{FailurePolicy: platform.FailOpen}, + wrapDeniesWrite: false, // wrap would normally allow + } + // Override Wrap with a malicious behavior: return nil (silence the + // denial). We do this by wrapping the install: register a + // second Wrap that suppresses errors. + platform.Register(plugin) + + // Add another plugin with a malicious wrap. + malicious := &mockMaliciousPlugin{ + name: "malicious", + invokedFlag: &wrapCalled, + } + platform.Register(malicious) + + result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + + root, leaf := syntheticTree() + // Simulate cmdpolicy.Apply marking leaf as denied. + leaf.Hidden = true + leaf.DisableFlagParsing = true + if leaf.Annotations == nil { + leaf.Annotations = map[string]string{} + } + leaf.Annotations["lark:policy_denied_layer"] = "policy" + leaf.Annotations["lark:policy_denied_source"] = "plugin:other" + denyStubCalled := false + leaf.RunE = func(*cobra.Command, []string) error { + denyStubCalled = true + return errors.New("CommandPruned (denyStub)") + } + + if err := wireHooks(context.Background(), root, result.Registry); err != nil { + t.Fatalf("wireHooks: %v", err) + } + + err = leaf.RunE(leaf, nil) + if wrapCalled { + t.Errorf("denial guard violated: malicious Wrap ran on a denied command") + } + if !denyStubCalled { + t.Errorf("denyStub should run on the denial path even when a Wrap is registered") + } + if err == nil { + t.Errorf("denial error must propagate, got nil") + } +} + +// mockMaliciousPlugin registers a Wrap that returns nil unconditionally +// -- exactly the kind of plugin the denial guard defends against. +type mockMaliciousPlugin struct { + name string + invokedFlag *bool +} + +func (p *mockMaliciousPlugin) Name() string { return p.name } +func (p *mockMaliciousPlugin) Version() string { return "0.0.1" } +func (p *mockMaliciousPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{FailurePolicy: platform.FailOpen} +} +func (p *mockMaliciousPlugin) Install(r platform.Registrar) error { + r.Wrap("hijack", platform.All(), + func(_ platform.Handler) platform.Handler { + return func(context.Context, platform.Invocation) error { + if p.invokedFlag != nil { + *p.invokedFlag = true + } + return nil // silence everything + } + }) + return nil +} + +// Verifies buildInternal returns a non-nil *hook.Registry when a plugin +// is registered and Emit(Shutdown) on that registry fires the plugin's +// On(Shutdown) handler. This is the contract Execute relies on to fire +// Shutdown after rootCmd.Execute returns. +func TestBuildInternal_returnsRegistryForShutdownEmit(t *testing.T) { + tmpHome(t) + + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + plugin := &fakeIntegrationPlugin{ + name: "shutdown-test", + caps: platform.Capabilities{FailurePolicy: platform.FailOpen}, + } + platform.Register(plugin) + + _, _, reg := buildInternal(context.Background(), buildInvocationForTest(t)) + if reg == nil { + t.Fatalf("buildInternal returned nil registry; plugin's Shutdown handler is unreachable") + } + + if err := hook.Emit(context.Background(), reg, platform.Shutdown, nil); err != nil { + t.Fatalf("Emit(Shutdown): %v", err) + } + if got := atomic.LoadInt64(&plugin.shutdownCalled); got != 1 { + t.Errorf("On(Shutdown) handler fired %d times, want 1", got) + } +} + +// When plugin install fails (FailClosed), buildInternal returns nil +// registry. Execute must nil-check before calling Emit so we don't fault +// on the FailClosed bypass-guard path. +func TestBuildInternal_failClosedYieldsNilRegistry(t *testing.T) { + tmpHome(t) + + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + // A plugin that fails install and is FailClosed -> InstallAll + // returns an error, buildInternal installs the guard and returns + // early with nil registry. + plugin := &failingPlugin{ + name: "fail-closed", + caps: platform.Capabilities{FailurePolicy: platform.FailClosed}, + err: errors.New("install failure simulated"), + } + platform.Register(plugin) + + _, _, reg := buildInternal(context.Background(), buildInvocationForTest(t)) + if reg != nil { + t.Errorf("buildInternal returned non-nil registry on FailClosed install error") + } +} + +type failingPlugin struct { + name string + caps platform.Capabilities + err error +} + +func (p *failingPlugin) Name() string { return p.name } +func (p *failingPlugin) Version() string { return "0.0.1" } +func (p *failingPlugin) Capabilities() platform.Capabilities { return p.caps } +func (p *failingPlugin) Install(platform.Registrar) error { return p.err } + +// === Plugin Restrict conflict guard === +// +// Two plugins both calling r.Restrict must surface as a structured +// plugin_conflict envelope (reason_code multiple_restrict_plugins) at +// dispatch time, NOT as a silent stderr warning. Otherwise a +// safety-sensitive operator could miss that their policy never took +// effect. +func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) { + tmpHome(t) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + rule := &platform.Rule{Name: "any", Allow: []string{"**"}} + platform.Register(&fakeIntegrationPlugin{ + name: "plugin-a", + caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}, + rule: rule, + }) + platform.Register(&fakeIntegrationPlugin{ + name: "plugin-b", + caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}, + rule: rule, + }) + + _, root, reg := buildInternal(context.Background(), buildInvocationForTest(t)) + if reg != nil { + t.Errorf("conflict guard path should yield nil registry") + } + + // Pick any leaf and verify it returns the structured envelope. + leaf := findRunnableLeaf(root) + if leaf == nil { + t.Fatalf("no runnable leaf in command tree") + } + err := leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "plugin_conflict" { + t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type) + } + if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" { + t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc) + } +} + +// Single plugin with an invalid Rule must surface as plugin_install / +// invalid_rule envelope (distinct error.type from multi-Restrict). +func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) { + tmpHome(t) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + // MaxRisk "nukem" is rejected by ValidateRule -> Resolve returns + // an error that is NOT ErrMultipleRestricts. + platform.Register(&fakeIntegrationPlugin{ + name: "bad", + caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}, + rule: &platform.Rule{Name: "bad", MaxRisk: "nukem"}, + }) + + _, root, reg := buildInternal(context.Background(), buildInvocationForTest(t)) + if reg != nil { + t.Errorf("conflict guard path should yield nil registry") + } + leaf := findRunnableLeaf(root) + if leaf == nil { + t.Fatalf("no runnable leaf in command tree") + } + err := leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "plugin_install" { + t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type) + } + if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" { + t.Errorf("reason_code = %v, want invalid_rule", rc) + } +} + +// === Startup lifecycle guard === +// +// Plugin On(Startup) handler returning error must abort startup with +// a plugin_lifecycle envelope (reason_code lifecycle_failed). Silently +// continuing would leave the plugin's invariants violated while the +// rest of its hooks still fire. +func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) { + tmpHome(t) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + platform.Register(&startupFailingPlugin{ + name: "lc", + failErr: errors.New("backend unreachable"), + }) + + _, root, reg := buildInternal(context.Background(), buildInvocationForTest(t)) + if reg != nil { + t.Errorf("lifecycle guard path should yield nil registry") + } + + leaf := findRunnableLeaf(root) + err := leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "plugin_lifecycle" { + t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type) + } + d := exitErr.Detail.Detail.(map[string]any) + if d["reason_code"] != "lifecycle_failed" { + t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"]) + } + if d["hook_name"] != "lc.start" { + t.Errorf("hook_name = %v, want lc.start", d["hook_name"]) + } +} + +// Same path but the handler panics -> reason_code lifecycle_panic. +func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) { + tmpHome(t) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + platform.Register(&startupFailingPlugin{ + name: "lc", + doPanic: true, + panicMsg: "kaboom", + }) + + _, root, reg := buildInternal(context.Background(), buildInvocationForTest(t)) + if reg != nil { + t.Errorf("lifecycle guard path should yield nil registry") + } + leaf := findRunnableLeaf(root) + err := leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" { + t.Errorf("reason_code = %v, want lifecycle_panic", rc) + } +} + +type startupFailingPlugin struct { + name string + failErr error // when set, handler returns this + doPanic bool // when true, handler panics with panicMsg + panicMsg string +} + +func (p *startupFailingPlugin) Name() string { return p.name } +func (p *startupFailingPlugin) Version() string { return "0.0.1" } +func (p *startupFailingPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{FailurePolicy: platform.FailClosed} +} +func (p *startupFailingPlugin) Install(r platform.Registrar) error { + r.On(platform.Startup, "start", func(context.Context, *platform.LifecycleContext) error { + if p.doPanic { + panic(p.panicMsg) + } + return p.failErr + }) + return nil +} + +// === Wrapper panic recovery === +// +// A Wrapper that panics must NOT crash the process. The framework +// recovers and converts to a structured envelope: +// +// type="hook", reason_code="panic", hook_name= +func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + platform.Register(&panickingWrapPlugin{name: "p"}) + + result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + root, leaf := syntheticTree() + if err := wireHooks(context.Background(), root, result.Registry); err != nil { + t.Fatalf("wireHooks: %v", err) + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("Wrapper panic must be recovered, but it escaped: %v", r) + } + }() + + err = leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "hook" { + t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + } + d := exitErr.Detail.Detail.(map[string]any) + if d["reason_code"] != "panic" { + t.Errorf("reason_code = %v, want panic", d["reason_code"]) + } + if d["hook_name"] != "p.boom" { + t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"]) + } +} + +type panickingWrapPlugin struct{ name string } + +func (p *panickingWrapPlugin) Name() string { return p.name } +func (p *panickingWrapPlugin) Version() string { return "0.0.1" } +func (p *panickingWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} } +func (p *panickingWrapPlugin) Install(r platform.Registrar) error { + r.Wrap("boom", platform.All(), + func(_ platform.Handler) platform.Handler { + return func(context.Context, platform.Invocation) error { + panic("intentional panic for test") + } + }) + return nil +} + +// findRunnableLeaf walks the tree and returns the first command with a +// RunE so tests can synthesize a dispatch without going through cobra. +func findRunnableLeaf(c *cobra.Command) *cobra.Command { + if c.RunE != nil && c.HasParent() { + return c + } + for _, child := range c.Commands() { + if l := findRunnableLeaf(child); l != nil { + return l + } + } + return nil +} + +// B2 regression: a plugin Wrapper whose FACTORY function (the +// `func(next Handler) Handler` itself) panics must not crash the +// process. The framework recovers and returns the same panic envelope +// it produces for runtime panics inside the inner Handler. +// +// Pre-fix code path: recoverWrap had `inner := w(next)` outside the +// deferred recover, so a factory panic escaped. +func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + platform.Register(&factoryPanicWrapPlugin{name: "fac"}) + + result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + root, leaf := syntheticTree() + if err := wireHooks(context.Background(), root, result.Registry); err != nil { + t.Fatalf("wireHooks: %v", err) + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("factory panic must be recovered, but it escaped: %v", r) + } + }() + + err = leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "hook" { + t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + } + d := exitErr.Detail.Detail.(map[string]any) + if d["reason_code"] != "panic" { + t.Errorf("reason_code = %v, want panic", d["reason_code"]) + } + if d["hook_name"] != "fac.bad-factory" { + t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"]) + } +} + +type factoryPanicWrapPlugin struct{ name string } + +func (p *factoryPanicWrapPlugin) Name() string { return p.name } +func (p *factoryPanicWrapPlugin) Version() string { return "0.0.1" } +func (p *factoryPanicWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} } +func (p *factoryPanicWrapPlugin) Install(r platform.Registrar) error { + r.Wrap("bad-factory", platform.All(), + // The factory itself panics; the returned Handler is never reached. + func(_ platform.Handler) platform.Handler { + panic("factory blew up") + }) + return nil +} diff --git a/cmd/profile/add.go b/cmd/profile/add.go index d84e1f504..a657bccb9 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -45,6 +45,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command { _ = cmd.MarkFlagRequired("name") _ = cmd.MarkFlagRequired("app-id") + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/profile/list.go b/cmd/profile/list.go index dbe98c1e7..fb4cc1ffe 100644 --- a/cmd/profile/list.go +++ b/cmd/profile/list.go @@ -34,6 +34,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command { return profileListRun(f) }, } + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/profile/remove.go b/cmd/profile/remove.go index 124c32e58..08c19234e 100644 --- a/cmd/profile/remove.go +++ b/cmd/profile/remove.go @@ -28,6 +28,7 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command { cmdutil.SetTips(cmd, []string{ "AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.", }) + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/profile/rename.go b/cmd/profile/rename.go index 37fbc787a..2a8f6a2e5 100644 --- a/cmd/profile/rename.go +++ b/cmd/profile/rename.go @@ -24,6 +24,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command { return profileRenameRun(f, args[0], args[1]) }, } + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/profile/use.go b/cmd/profile/use.go index de0964d7e..013ade47e 100644 --- a/cmd/profile/use.go +++ b/cmd/profile/use.go @@ -27,6 +27,7 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command { cmdutil.SetTips(cmd, []string{ "AI agents: Do NOT switch profiles unless the user explicitly asks.", }) + cmdutil.SetRisk(cmd, "write") return cmd } diff --git a/cmd/prune.go b/cmd/prune.go index 1a3f05f52..1f503517e 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -7,10 +7,12 @@ import ( "fmt" "slices" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" - "github.com/spf13/cobra" ) // pruneForStrictMode removes commands incompatible with the active strict mode. @@ -43,15 +45,76 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) { } func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command { + // The denial annotations let the hook layer's populateInvocationDenial + // recognise this command as denied, so the Wrap chain is physically + // isolated (wrapRunE takes the DeniedByPolicy branch and calls the + // stub RunE directly). Without these, a plugin Wrapper registered + // against platform.All() could intercept and silently swallow the + // strict-mode error -- breaking strict-mode's "hard boundary" contract. + // + // Args + PersistentPreRunE overrides mirror cmdpolicy/apply.go::installDenyStub: + // + // - Args=ArbitraryArgs: with DisableFlagParsing the user's flags + // look like positional args; the original child's Args validator + // (e.g. cobra.NoArgs) would fire BEFORE RunE and produce a + // cobra usage error instead of our strict_mode envelope. + // + // - PersistentPreRunE no-op: cmd/auth/auth.go declares a parent + // PersistentPreRunE that returns external_provider when env + // credentials are set. Cobra's "first wins walking up" would + // pick auth's instead of our denial. A leaf-level no-op makes + // cobra stop here and proceed to the wrapped RunE. + // + // strict-mode keeps its short Message + independent Hint and + // composes the shared detail.* / wrapped-CommandDeniedError shape + // by hand; BuildDenialError would override Message with the + // CommandDeniedError.Error() long form. + stubMessage := fmt.Sprintf( + "strict mode is %q, only %s-identity commands are available", + mode, mode.ForcedIdentity()) + const stubHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)" + denial := cmdpolicy.Denial{ + Layer: cmdpolicy.LayerStrictMode, + PolicySource: "strict-mode", + ReasonCode: "identity_not_supported", + Reason: stubMessage, + } + // Preserve the original command's annotations (risk_level, + // lark:supportedIdentities, cmdmeta.domain, ...) and help text so + // audit / compliance observers can still see what was denied. + // Stamp the denial annotations on top. + annotations := make(map[string]string, len(child.Annotations)+2) + for k, v := range child.Annotations { + annotations[k] = v + } + annotations[cmdpolicy.AnnotationDenialLayer] = cmdpolicy.LayerStrictMode + annotations[cmdpolicy.AnnotationDenialSource] = "strict-mode" + return &cobra.Command{ Use: child.Use, Aliases: append([]string(nil), child.Aliases...), + Short: child.Short, + Long: child.Long, Hidden: true, DisableFlagParsing: true, - RunE: func(cmd *cobra.Command, args []string) error { - return output.ErrWithHint(output.ExitValidation, "strict_mode", - fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()), - "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") + Args: cobra.ArbitraryArgs, + Annotations: annotations, + PersistentPreRunE: func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + }, + RunE: func(c *cobra.Command, _ []string) error { + cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial) + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "command_denied", + Message: stubMessage, + Hint: stubHint, + Detail: cmdpolicy.DenialDetailMap(cd), + }, + Err: cd, + } }, } } diff --git a/cmd/prune_test.go b/cmd/prune_test.go index 8d0594737..d9a949c36 100644 --- a/cmd/prune_test.go +++ b/cmd/prune_test.go @@ -4,11 +4,15 @@ package cmd import ( + "errors" "strings" "testing" + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -198,3 +202,176 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T t.Fatalf("unexpected error: %v", err) } } + +// Regression for codex C13: a strict-mode stub whose PARENT declares +// a PersistentPreRunE (e.g. cmd/auth/auth.go's external_provider +// check on env credentials) must surface the strict_mode envelope, +// not the parent's error. Cobra's "first PersistentPreRunE wins +// walking up from leaf" semantics will pick the parent's unless the +// stub itself carries its own. +// +// Fix: strictModeStubFrom installs a no-op PersistentPreRunE so cobra +// stops at the stub and proceeds to its RunE. +func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) { + root := newTestTree() + pruneForStrictMode(root, core.StrictModeBot) + stub := findCmd(root, "auth", "login") + if stub == nil { + t.Fatal("auth/login stub should exist after StrictModeBot") + } + if stub.PersistentPreRunE == nil { + t.Fatal("strict-mode stub must declare PersistentPreRunE on leaf") + } + if err := stub.PersistentPreRunE(stub, nil); err != nil { + t.Errorf("strict-mode stub PersistentPreRunE should be no-op, got %v", err) + } +} + +// Regression for codex H13: strict-mode stub must accept arbitrary +// positional args. With DisableFlagParsing=true, a user passing +// `auth login --scope ...` looks like 4 positional args; the original +// cobra.Args validator would surface a usage error BEFORE strict-mode +// stub's RunE. +func TestStrictModeStub_BypassesArgsValidator(t *testing.T) { + root := newTestTree() + pruneForStrictMode(root, core.StrictModeBot) + stub := findCmd(root, "auth", "login") + if stub == nil { + t.Fatal("auth/login stub should exist after StrictModeBot") + } + if stub.Args == nil { + t.Fatal("strict-mode stub must declare Args validator") + } + if err := stub.Args(stub, []string{"--scope", "im.message", "--profile", "default"}); err != nil { + t.Errorf("strict-mode stub Args should accept flag-like args, got %v", err) + } +} + +// Pins the strict-mode envelope shape: structured detail.* / wrapped +// CommandDeniedError for external agents, AND the historical short +// Message + independent Hint for existing consumers. +func TestStrictModeStub_StructuredEnvelope(t *testing.T) { + root := newTestTree() + pruneForStrictMode(root, core.StrictModeBot) + stub := findCmd(root, "im", "+search") + if stub == nil { + t.Fatalf("expected im/+search stub") + } + err := stub.RunE(stub, nil) + if err == nil { + t.Fatalf("strict-mode stub RunE should return error") + } + + var ee *output.ExitError + if !errors.As(err, &ee) { + t.Fatalf("err is not *output.ExitError: %T", err) + } + if ee.Detail == nil { + t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON") + } + if ee.Detail.Type != "command_denied" { + t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type) + } + dm, ok := ee.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail) + } + if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode { + t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode) + } + if got, _ := dm["reason_code"].(string); got != "identity_not_supported" { + t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got) + } + if got, _ := dm["policy_source"].(string); got != "strict-mode" { + t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got) + } + + var cd *platform.CommandDeniedError + if !errors.As(err, &cd) { + t.Fatalf("err does not unwrap to *platform.CommandDeniedError") + } + if cd.Layer != cmdpolicy.LayerStrictMode { + t.Errorf("CommandDeniedError.Layer = %q, want %q", cd.Layer, cmdpolicy.LayerStrictMode) + } + if cd.ReasonCode != "identity_not_supported" { + t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode) + } + if !strings.Contains(cd.Reason, `strict mode is "bot"`) { + t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason) + } + if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` { + t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message) + } + if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") { + t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint) + } +} + +// strictModeStubFrom must write the denial annotations so the hook +// layer's populateInvocationDenial recognises the command as denied +// and physically isolates the Wrap chain. Without this, a plugin +// Wrapper registered against platform.All() could intercept the stub +// and silently return nil, swallowing the strict-mode error. +func TestStrictModeStub_HasDenialAnnotation(t *testing.T) { + root := newTestTree() + pruneForStrictMode(root, core.StrictModeBot) + + // im/+search is user-only -> replaced by a stub in StrictModeBot. + stub := findCmd(root, "im", "+search") + if stub == nil { + t.Fatalf("expected im/+search stub to exist") + } + got := stub.Annotations[cmdpolicy.AnnotationDenialLayer] + if got != cmdpolicy.LayerStrictMode { + t.Errorf("stub annotation %q = %q, want %q", + cmdpolicy.AnnotationDenialLayer, got, cmdpolicy.LayerStrictMode) + } + if src := stub.Annotations[cmdpolicy.AnnotationDenialSource]; src != "strict-mode" { + t.Errorf("stub annotation %q = %q, want %q", + cmdpolicy.AnnotationDenialSource, src, "strict-mode") + } +} + +// Audit / compliance observers fire even for strict-mode-denied commands +// and rely on CommandView.Risk() / Identities() / etc. The stub must +// carry the original command's annotations so those accessors keep +// returning meaningful values; the Short/Long are preserved so `--help` +// on a denied command still describes the original intent (parity with +// cmdpolicy/apply.go::installDenyStub). +func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) { + root := &cobra.Command{Use: "root"} + svc := &cobra.Command{Use: "im"} + root.AddCommand(svc) + userOnly := &cobra.Command{ + Use: "+search", + Short: "search messages", + Long: "Search across IM history.", + RunE: func(*cobra.Command, []string) error { return nil }, + } + cmdutil.SetSupportedIdentities(userOnly, []string{"user"}) + cmdutil.SetRisk(userOnly, "read") + svc.AddCommand(userOnly) + + pruneForStrictMode(root, core.StrictModeBot) + + stub := findCmd(root, "im", "+search") + if stub == nil { + t.Fatalf("expected im/+search stub") + } + if got := stub.Annotations["risk_level"]; got != "read" { + t.Errorf("stub risk_level = %q, want %q (lost in replacement)", got, "read") + } + if got := stub.Annotations["lark:supportedIdentities"]; got != "user" { + t.Errorf("stub supportedIdentities = %q, want %q", got, "user") + } + if stub.Short != "search messages" { + t.Errorf("stub Short = %q, want preserved Short", stub.Short) + } + if stub.Long != "Search across IM history." { + t.Errorf("stub Long = %q, want preserved Long", stub.Long) + } + // Denial stamps must still be present. + if stub.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerStrictMode { + t.Errorf("denial annotation overwritten or missing") + } +} diff --git a/cmd/root.go b/cmd/root.go index 54fb5ed34..00d9a24bc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,12 +12,17 @@ import ( "io" "net/url" "os" + "sort" "strconv" + "strings" + "github.com/larksuite/cli/extension/platform" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/skillscheck" @@ -88,8 +93,9 @@ func Execute() int { } configureFlagCompletions(os.Args) - f, rootCmd := buildInternal( - context.Background(), inv, + ctx := context.Background() + f, rootCmd, reg := buildInternal( + ctx, inv, WithIO(os.Stdin, os.Stdout, os.Stderr), HideProfile(isSingleAppMode()), ) @@ -99,8 +105,18 @@ func Execute() int { setupNotices() } - if err := rootCmd.Execute(); err != nil { - return handleRootError(f, err) + runErr := rootCmd.Execute() + + // Fire Shutdown lifecycle hooks regardless of run outcome. + // emitShutdown imposes a 2s total deadline and never propagates handler + // errors (Emit's documented Shutdown contract), so it cannot block exit + // or alter the user-visible exit code. + if reg != nil && !isCompletionCommand(os.Args) { + _ = hook.Emit(ctx, reg, platform.Shutdown, runErr) + } + + if runErr != nil { + return handleRootError(f, runErr) } return 0 } @@ -159,11 +175,17 @@ func setupNotices() { } // isCompletionCommand returns true if args indicate a shell completion request. -// Update notifications must be suppressed for these to avoid corrupting -// machine-parseable completion output. +// Update notifications and Shutdown lifecycle emits must be suppressed for +// these to avoid corrupting machine-parseable completion output and to avoid +// firing plugin Shutdown handlers on every Tab keystroke. +// +// Cobra dispatches BOTH "__complete" and its alias "__completeNoDesc" through +// the same hidden subcommand (see cobra/completions.go ShellCompRequestCmd / +// ShellCompNoDescRequestCmd). Check both, otherwise bash/zsh completion +// (which often uses NoDesc) silently bypasses the gate. func isCompletionCommand(args []string) bool { for _, arg := range args { - if arg == "completion" || arg == "__complete" { + if arg == "completion" || arg == "__complete" || arg == "__completeNoDesc" { return true } } @@ -263,6 +285,70 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr fmt.Fprint(w, buffer.String()) } +// installUnknownSubcommandGuard replaces cobra's silent help fallback on +// group commands (no Run/RunE) with an unknown_subcommand error. +// +// IMPORTANT: every command modified here is also tagged with +// cmdpolicy.AnnotationPureGroup so the user-layer policy engine +// continues to treat the command as a pure parent group. Without the +// tag, the RunE injection here would flip Runnable()=true and a user +// rule like `max_risk: read` would deny every ` --help` call +// with reason_code=risk_not_annotated. +func installUnknownSubcommandGuard(cmd *cobra.Command) { + if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { + cmd.RunE = unknownSubcommandRunE + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[cmdpolicy.AnnotationPureGroup] = "true" + } + for _, c := range cmd.Commands() { + installUnknownSubcommandGuard(c) + } +} + +func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + unknown := args[0] + available := availableSubcommandNames(cmd) + msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) + hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) + if len(available) > 0 { + hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", ")) + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "unknown_subcommand", + Message: msg, + Hint: hint, + Detail: map[string]any{ + "unknown": unknown, + "command_path": cmd.CommandPath(), + "available": available, + }, + }, + } +} + +func availableSubcommandNames(cmd *cobra.Command) []string { + subs := make([]string, 0, len(cmd.Commands())) + for _, c := range cmd.Commands() { + if c.Hidden || !c.IsAvailableCommand() { + continue + } + name := c.Name() + if name == "help" || name == "completion" { + continue + } + subs = append(subs, name) + } + sort.Strings(subs) + return subs +} + // installTipsHelpFunc wraps the default help function to append a TIPS section // when a command has tips set via cmdutil.SetTips. It also force-shows global // flags that are normally hidden in single-app mode (currently --profile) diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 416777a44..a8919d1ce 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -27,6 +27,14 @@ import ( "github.com/spf13/cobra" ) +// Canonical strict-mode envelope strings shared across fixtures +// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom). +const ( + strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available` + strictModeUserMessage = `strict mode is "user", only user-identity commands are available` + strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)" +) + // buildIntegrationRootCmd creates a root command with api, service, and shortcut // subcommands wired to a test factory, simulating the real CLI command tree. func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command { @@ -353,9 +361,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ OK: false, Error: &output.ErrDetail{ - Type: "strict_mode", - Message: `strict mode is "bot", only bot-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", + Type: "command_denied", + Message: strictModeBotMessage, + Hint: strictModeHint, + Detail: map[string]any{ + "path": "auth/login", + "layer": "strict_mode", + "policy_source": "strict-mode", + "rule_name": "", + "reason_code": "identity_not_supported", + "reason": strictModeBotMessage, + }, }, }) } @@ -371,9 +387,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ OK: false, Error: &output.ErrDetail{ - Type: "strict_mode", - Message: `strict mode is "bot", only bot-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", + Type: "command_denied", + Message: strictModeBotMessage, + Hint: strictModeHint, + Detail: map[string]any{ + "path": "im/+messages-search", + "layer": "strict_mode", + "policy_source": "strict-mode", + "rule_name": "", + "reason_code": "identity_not_supported", + "reason": strictModeBotMessage, + }, }, }) } @@ -409,7 +433,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn OK: false, Identity: "bot", Error: &output.ErrDetail{ - Type: "strict_mode", + Type: "command_denied", Message: `strict mode is "user", only user-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -428,7 +452,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "strict_mode", + Type: "command_denied", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -446,9 +470,17 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ OK: false, Error: &output.ErrDetail{ - Type: "strict_mode", - Message: `strict mode is "user", only user-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", + Type: "command_denied", + Message: strictModeUserMessage, + Hint: strictModeHint, + Detail: map[string]any{ + "path": "im/images/create", + "layer": "strict_mode", + "policy_source": "strict-mode", + "rule_name": "", + "reason_code": "identity_not_supported", + "reason": strictModeUserMessage, + }, }, }) } @@ -465,7 +497,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "strict_mode", + Type: "command_denied", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, diff --git a/cmd/root_test.go b/cmd/root_test.go index 0f5ac1ad9..6aac983db 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -356,6 +356,7 @@ func TestConfigureFlagCompletions(t *testing.T) { {"help flag", []string{"im", "--help"}, true}, {"no args", []string{}, true}, {"__complete request", []string{"__complete", "im", "+send", ""}, false}, + {"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false}, {"completion subcommand", []string{"completion", "bash"}, false}, } for _, tc := range tests { @@ -368,3 +369,30 @@ func TestConfigureFlagCompletions(t *testing.T) { }) } } + +// isCompletionCommand must classify BOTH cobra completion aliases as +// completion requests so the Shutdown emit and update-notice paths skip +// shell-completion invocations. __completeNoDesc is an Alias of +// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and +// dispatches the same RunE; bash/zsh completion typically calls the +// NoDesc variant. +func TestIsCompletionCommand(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {"plain command", []string{"im", "+send"}, false}, + {"__complete", []string{"__complete", "im"}, true}, + {"__completeNoDesc", []string{"__completeNoDesc", "im"}, true}, + {"completion subcommand", []string{"completion", "bash"}, true}, + {"completion in tail", []string{"foo", "bar", "completion"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isCompletionCommand(tc.args); got != tc.want { + t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want) + } + }) + } +} diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 38ecaa322..e4114c5bc 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -380,6 +380,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp }) + cmdutil.SetRisk(cmd, "read") return cmd } diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go new file mode 100644 index 000000000..4bba607d5 --- /dev/null +++ b/cmd/unknown_subcommand_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/output" +) + +func newGroupTree() (root, drive, files *cobra.Command) { + root = &cobra.Command{Use: "lark-cli"} + drive = &cobra.Command{Use: "drive", Short: "drive ops"} + root.AddCommand(drive) + + search := &cobra.Command{Use: "+search", RunE: func(*cobra.Command, []string) error { return nil }} + upload := &cobra.Command{Use: "+upload", RunE: func(*cobra.Command, []string) error { return nil }} + hidden := &cobra.Command{Use: "+secret", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }} + drive.AddCommand(search, upload, hidden) + + files = &cobra.Command{Use: "files", Short: "files ops"} + drive.AddCommand(files) + files.AddCommand(&cobra.Command{Use: "list", RunE: func(*cobra.Command, []string) error { return nil }}) + + return root, drive, files +} + +func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) { + root, drive, files := newGroupTree() + leaf := drive.Commands()[0] // +search + + installUnknownSubcommandGuard(root) + + if drive.RunE == nil { + t.Error("drive should have RunE installed") + } + if files.RunE == nil { + t.Error("files should have RunE installed") + } + if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil { + t.Errorf("leaf +search RunE should be untouched, got error %v", err) + } +} + +func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + called := false + custom := &cobra.Command{ + Use: "custom", + RunE: func(*cobra.Command, []string) error { + called = true + return nil + }, + } + // Child makes custom a "group" command, exercising the Run/RunE override guard. + custom.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.AddCommand(custom) + + installUnknownSubcommandGuard(root) + + if err := custom.RunE(custom, nil); err != nil { + t.Fatalf("preserved RunE returned error: %v", err) + } + if !called { + t.Error("guard must not overwrite a command that already defines Run/RunE") + } +} + +func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) { + _, drive, _ := newGroupTree() + installUnknownSubcommandGuard(drive.Root()) + + var buf bytes.Buffer + drive.SetOut(&buf) + drive.SetErr(&buf) + + if err := drive.RunE(drive, nil); err != nil { + t.Fatalf("expected no-args invocation to succeed, got: %v", err) + } + if !strings.Contains(buf.String(), "drive ops") { + t.Errorf("expected help output to include the command's Short, got:\n%s", buf.String()) + } +} + +func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) { + _, drive, _ := newGroupTree() + installUnknownSubcommandGuard(drive.Root()) + + err := drive.RunE(drive, []string{"+bogus"}) + if err == nil { + t.Fatal("expected error for unknown subcommand") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code) + } + if exitErr.Detail == nil { + t.Fatal("expected ExitError to carry Detail") + } + if exitErr.Detail.Type != "unknown_subcommand" { + t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) { + t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") { + t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint) + } + if strings.Contains(exitErr.Detail.Hint, "+secret") { + t.Error("hidden commands must not appear in the hint") + } + + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail) + } + if detail["unknown"] != "+bogus" { + t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"]) + } + if detail["command_path"] != "lark-cli drive" { + t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"]) + } + available, ok := detail["available"].([]string) + if !ok { + t.Fatalf("detail.available should be []string, got %T", detail["available"]) + } + if len(available) != 3 { + t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available) + } +} + +func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) { + root, _, files := newGroupTree() + installUnknownSubcommandGuard(root) + + err := files.RunE(files, []string{"bogus"}) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError on nested group, got %T", err) + } + if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" { + t.Errorf("command_path should reflect the nested resource, got %v", + exitErr.Detail.Detail.(map[string]any)["command_path"]) + } +} + +func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + root.AddCommand( + &cobra.Command{Use: "alpha", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "help", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "completion", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "beta", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + got := availableSubcommandNames(root) + want := []string{"alpha", "gamma"} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i, name := range want { + if got[i] != name { + t.Errorf("availableSubcommandNames[%d] = %q, want %q", i, got[i], name) + } + } +} diff --git a/cmd/update/update.go b/cmd/update/update.go index 632e4fe9e..c9035cd5c 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -111,6 +111,7 @@ Use --check to only check for updates without installing.`, cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date") cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install") + cmdutil.SetRisk(cmd, "high-risk-write") return cmd } diff --git a/extension/platform/README.md b/extension/platform/README.md new file mode 100644 index 000000000..d2834ddd7 --- /dev/null +++ b/extension/platform/README.md @@ -0,0 +1,186 @@ +# lark-cli Plugin SDK + +`extension/platform` is the **in-process plugin SDK** for lark-cli. +Plugins compile into a **fork** of the lark-cli binary via a blank +import; there is no `.so` loading, no RPC, no subprocess isolation. +A plugin shares the binary's address space and lifecycle. + +## 5-minute hello world + +```go +// myplugin/audit.go +package myplugin + +import ( + "context" + "log" + + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("audit", "0.1.0"). + Observer(platform.After, "log-cmd", platform.All(), + func(ctx context.Context, inv platform.Invocation) { + log.Printf("cmd=%s err=%v", inv.Cmd().Path(), inv.Err()) + }). + FailOpen(). + MustBuild()) +} +``` + +Wire into a fork: + +```go +// cmd/larkx/main.go in your fork +package main + +import ( + _ "github.com/me/myplugin" // blank import → init() runs + + "github.com/larksuite/cli/cmd" + "os" +) + +func main() { os.Exit(cmd.Execute()) } +``` + +```sh +go build -o larkx ./cmd/larkx && ./larkx config plugins show +``` + +You should see `audit` in the plugin list. + +## What you can hook + +| Hook | Fires | Can block? | +| -------------------------- | ---------------------------------- | -------------------------------- | +| `Observer` | Before / After each command | No (fire-and-forget audit) | +| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) | +| `On(Startup/Shutdown)` | Process lifecycle | N/A | +| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees | + +### Plugin lifecycle + +```mermaid +sequenceDiagram + participant Host as lark-cli (host) + participant SDK as platform (SDK) + participant Plugin as your plugin + + Note over Host,Plugin: Process start (before main) + Plugin->>Plugin: init() (via blank import) + Plugin->>SDK: Register(plugin) + + Note over Host,Plugin: Bootstrap (host main) + Host->>SDK: RegisteredPlugins() + SDK-->>Host: snapshot in registration order + Host->>SDK: InstallAll() + SDK->>Plugin: Capabilities() + SDK->>Plugin: Install(Registrar) + Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown) + SDK->>Plugin: On(Startup) fire + + Note over Host,Plugin: Each command dispatch + Host->>SDK: hook chain (in registration order) + SDK->>Plugin: Observer Before + SDK->>Plugin: Wrap (around RunE) + SDK->>Plugin: Observer After + + Note over Host,Plugin: Process exit + Host->>SDK: Emit(Shutdown) + SDK->>Plugin: On(Shutdown) fire +``` + +A `command_denied` decision (from `Restrict` or strict-mode) bypasses +the `Wrap` chain entirely — observers still fire so audit plugins see +the rejected dispatch. + +## Safety contract (read this) + +- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder + flips it automatically; the lower-level `Plugin` interface rejects + the mismatch with `restricts_mismatch`. +- Only ONE plugin per binary can call `Restrict()`. Multi-plugin + Restrict is a deliberate `plugin_conflict` error (single-rule + ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is + shadowed by any plugin Restrict. +- The `Wrap` factory runs **once per command dispatch**, not at + install time. Long-lived state (clients, caches, metrics counters) + must live on the Plugin struct or in package-level variables. +- Plugins cannot suppress a `command_denied`: the framework + physically isolates denied commands from the Wrap chain (Observers + still fire). +- Commands missing a `risk_level` annotation are denied by default + when a Rule is active. Set `Rule.AllowUnannotated = true` (or + `allow_unannotated: true` in yaml) to opt out during gradual + adoption. +- Risk annotation typos (e.g. `"wrtie"`) are always denied with + `risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated` + does NOT bypass this — typo is a code bug, not a missing + annotation. + +## reason_code reference + +Every install / dispatch failure emits a `command_denied` or +`plugin_install` envelope carrying a `detail.reason_code` from the +closed enum below. Use the code (not the human-readable message) when +matching errors in agents, CI scripts, or downstream tools — the +messages are localised and may change between releases. + +### Plugin install (`error.type = plugin_install`) + +| reason_code | When it fires | Honours FailurePolicy? | +| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- | +| `invalid_plugin_name` | `Plugin.Name()` doesn't match `^[a-z0-9][a-z0-9-]*$` | No — always aborts | +| `plugin_name_panic` | `Plugin.Name()` panicked | No — always aborts | +| `duplicate_plugin_name` | Two plugins return the same `Name()` | No — always aborts | +| `capabilities_panic` | `Plugin.Capabilities()` panicked | Yes | +| `invalid_capability` | `Capabilities` malformed: bad `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts | +| `capability_unmet` | Current CLI version doesn't satisfy `RequiredCLIVersion` | Yes | +| `restricts_mismatch` | `Restricts=true` without `FailClosed`, or `Restricts` flag inconsistent w/ Install | No — always aborts | +| `invalid_hook_name` | Hook name contains `.` or doesn't match the plugin namespace | Yes | +| `duplicate_hook_name` | Same hook name registered twice within a plugin | Yes | +| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes | +| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes | +| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes | +| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes | +| `install_failed` | `Plugin.Install` returned a non-nil error | Yes | +| `install_panic` | `Plugin.Install` panicked | Yes | + +"No — always aborts" entries are treated as **untrusted-config errors**: +the host can't honour the plugin's declared `FailurePolicy` because the +declaration itself is suspect (e.g. an `invalid_capability` plugin +might also be lying about being `FailOpen`). + +### Command dispatch (`error.type = command_denied`) + +| reason_code | Meaning | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `risk_not_annotated` | Command has no `risk_level` annotation, and the active Rule does not set `allow_unannotated: true` | +| `risk_invalid` | Command's `risk_level` is a typo / not in the `read | write | high-risk-write` taxonomy (always fail-closed) | +| `command_denylisted` | Command path matched the active Rule's `deny` glob | +| `domain_not_allowed` | Active Rule has a non-empty `allow` list and the command path did not match any glob | +| `write_not_allowed` | Command risk is `write` / `high-risk-write` and exceeds Rule `max_risk` | +| `risk_too_high` | Command risk exceeds Rule `max_risk` but is not a write (reserved for future risk levels) | +| `identity_mismatch` | Command's `supportedIdentities` does not intersect Rule `identities` | +| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied | + +The `detail.layer` field distinguishes who rejected the call: +`policy` (this SDK's user-layer engine) vs. `strict_mode` +(`cmd/prune.go`'s credential-hardening pass). Agents that want to +dispatch on "any denial" should match `error.type == "command_denied"` +and ignore the layer; agents that only care about user-policy denials +should additionally check `detail.layer == "policy"`. + +## Where to go next + +- [Runnable example: audit observer](./examples/audit-observer/) +- [Runnable example: read-only policy](./examples/readonly-policy/) +- Builder API: see [`builder.go`](./builder.go) for the full DSL + (`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`, + `MustBuild`). +- Inventory diagnostic: run `lark-cli config plugins show` after + installing your plugin to see hooks/rules attributed to your plugin + name. diff --git a/extension/platform/abort.go b/extension/platform/abort.go new file mode 100644 index 000000000..9ec99d8b5 --- /dev/null +++ b/extension/platform/abort.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "fmt" + +// AbortError is returned by a Wrapper that wants to short-circuit the +// command chain (instead of calling next). The framework converts it +// to an *output.ExitError with type "hook" so the JSON envelope carries +// the structured fields agents expect. +// +// HookName is the framework-namespaced name ("secaudit.approval"); the +// Registrar adds the plugin-name prefix automatically. +// +// Cause and Detail are optional. Cause lets the consumer use +// errors.Is/As to find the underlying cause; Detail is serialized into +// envelope.detail under the "detail" key for agent consumption. +type AbortError struct { + HookName string + Reason string + Cause error + Detail any +} + +// Error renders a human-readable message; HookName + Reason + Cause are +// included when present. +func (e *AbortError) Error() string { + msg := fmt.Sprintf("hook %q aborted: %s", e.HookName, e.Reason) + if e.Cause != nil { + msg += ": " + e.Cause.Error() + } + return msg +} + +// Unwrap enables errors.Is / errors.As to traverse to Cause. +func (e *AbortError) Unwrap() error { return e.Cause } diff --git a/extension/platform/abort_test.go b/extension/platform/abort_test.go new file mode 100644 index 000000000..364f72fb5 --- /dev/null +++ b/extension/platform/abort_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "errors" + "io/fs" + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +func TestAbortError_messageFormats(t *testing.T) { + bare := &platform.AbortError{HookName: "secaudit.approval", Reason: "needs approval"} + if got := bare.Error(); got != `hook "secaudit.approval" aborted: needs approval` { + t.Errorf("Error() = %q", got) + } + + withCause := &platform.AbortError{ + HookName: "audit.upload", + Reason: "upstream unreachable", + Cause: fs.ErrNotExist, + } + if got := withCause.Error(); got == bare.Error() { + t.Errorf("Cause should be appended to message, got %q", got) + } +} + +// errors.As must traverse Unwrap so consumers can inspect the cause +// directly. This is the contract the host's wrapAbortError relies on. +func TestAbortError_unwrapErrorsAs(t *testing.T) { + root := fs.ErrPermission + ab := &platform.AbortError{ + HookName: "x", + Reason: "y", + Cause: root, + } + if !errors.Is(ab, fs.ErrPermission) { + t.Errorf("errors.Is should find fs.ErrPermission via Unwrap") + } +} diff --git a/extension/platform/builder.go b/extension/platform/builder.go new file mode 100644 index 000000000..1bcba749f --- /dev/null +++ b/extension/platform/builder.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import ( + "errors" + "fmt" + "regexp" +) + +// Builder is the ergonomic constructor for Plugin. Use it from init(): +// +// func init() { +// platform.Register( +// platform.NewPlugin("audit", "0.1.0"). +// Observer(platform.After, "log", platform.All(), auditFn). +// FailOpen(). +// MustBuild()) +// } +// +// The lower-level Plugin interface remains available for cases that +// need finer control (state on a struct, complex Install logic). The +// Builder enforces: +// +// - Name format (^[a-z0-9][a-z0-9-]*$) +// - hookName format and uniqueness within a plugin +// - Restricts ↔ FailClosed consistency (calling Restrict() implies +// FailClosed, so plugin authors cannot accidentally ship a policy +// plugin under FailOpen) +// - Rule validation via ValidateRule analogues (delegated to +// internal/cmdpolicy at install time; Builder only fast-fails +// blatantly bad input) +type Builder struct { + name string + version string + caps Capabilities + + actions []func(Registrar) + rule *Rule + + hookNames map[string]bool + errs []error +} + +var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// NewPlugin starts a Builder. Name format is validated lazily — errors +// surface at Build()/MustBuild() time, allowing chained calls without +// intermediate error handling. +func NewPlugin(name, version string) *Builder { + b := &Builder{ + name: name, + version: version, + hookNames: map[string]bool{}, + } + if !pluginNamePattern.MatchString(name) { + b.errs = append(b.errs, fmt.Errorf("invalid plugin name %q: must match ^[a-z0-9][a-z0-9-]*$", name)) + } + return b +} + +// RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint, +// e.g. ">=1.1.0"). Empty string means no requirement. +func (b *Builder) RequireCLI(constraint string) *Builder { + b.caps.RequiredCLIVersion = constraint + return b +} + +// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when +// neither FailOpen nor FailClosed is called and Restrict is not used. +func (b *Builder) FailOpen() *Builder { + b.caps.FailurePolicy = FailOpen + return b +} + +// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit +// when Restrict() is called. +func (b *Builder) FailClosed() *Builder { + b.caps.FailurePolicy = FailClosed + return b +} + +// Observer registers an Observer. Multiple calls accumulate. +func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder { + if !b.validateHookName(hookName, "observer") { + return b + } + // Capture by value so the action closure doesn't share state with + // subsequent Observer() calls (Go ≥1.22 already gives each call + // its own copies of parameter values, but pinning is explicit). + w, n, s, f := when, hookName, sel, fn + b.actions = append(b.actions, func(r Registrar) { + r.Observe(w, n, s, f) + }) + return b +} + +// Wrap registers a Wrapper. Multiple calls accumulate; the host +// composes them in registration order (outermost first). +func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder { + if !b.validateHookName(hookName, "wrap") { + return b + } + n, s, w := hookName, sel, wrap + b.actions = append(b.actions, func(r Registrar) { + r.Wrap(n, s, w) + }) + return b +} + +// On registers a LifecycleHandler. +func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder { + if !b.validateHookName(hookName, "on") { + return b + } + e, n, f := event, hookName, fn + b.actions = append(b.actions, func(r Registrar) { + r.On(e, n, f) + }) + return b +} + +// Restrict contributes a pruning Rule. Calling Restrict implicitly +// sets Restricts=true and FailurePolicy=FailClosed (the framework +// requires both to coexist; the builder enforces the pairing so the +// plugin author cannot accidentally ship a policy plugin under +// FailOpen). +func (b *Builder) Restrict(rule *Rule) *Builder { + if rule == nil { + b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil")) + return b + } + b.caps.Restricts = true + b.caps.FailurePolicy = FailClosed + b.rule = rule + return b +} + +// Build returns the configured Plugin, or an error if any builder +// step found a fault. MustBuild panics on the same error. +// +// The Restrict + FailOpen mismatch is checked here, not in the chained +// setters, because the two methods may be called in either order. +func (b *Builder) Build() (Plugin, error) { + if b.rule != nil && b.caps.FailurePolicy == FailOpen { + b.errs = append(b.errs, errors.New( + "Restrict() requires FailClosed; do not call FailOpen() after Restrict()")) + } + if len(b.errs) > 0 { + return nil, errors.Join(b.errs...) + } + return &builtPlugin{ + name: b.name, + version: b.version, + caps: b.caps, + actions: b.actions, + rule: b.rule, + }, nil +} + +// MustBuild panics if Build() would return an error. Designed for +// init(): +// +// func init() { platform.Register(platform.NewPlugin(...).MustBuild()) } +// +// A panic in init runs before the framework's recover guard is +// installed and will crash the binary. That is the intended +// behaviour: a misconfigured plugin must NOT be silently registered. +func (b *Builder) MustBuild() Plugin { + p, err := b.Build() + if err != nil { + panic(fmt.Sprintf("plugin %q: %v", b.name, err)) + } + return p +} + +// validateHookName checks the grammar and uniqueness; returns false +// when the name was rejected (caller skips the action). +func (b *Builder) validateHookName(hookName, kind string) bool { + if !pluginNamePattern.MatchString(hookName) { + b.errs = append(b.errs, fmt.Errorf( + "%s %q: hookName must match ^[a-z0-9][a-z0-9-]*$", kind, hookName)) + return false + } + if b.hookNames[hookName] { + b.errs = append(b.errs, fmt.Errorf( + "%s %q: hookName already used in this plugin", kind, hookName)) + return false + } + b.hookNames[hookName] = true + return true +} + +// builtPlugin is the Plugin implementation the builder emits. +type builtPlugin struct { + name string + version string + caps Capabilities + actions []func(Registrar) + rule *Rule +} + +func (p *builtPlugin) Name() string { return p.name } +func (p *builtPlugin) Version() string { return p.version } +func (p *builtPlugin) Capabilities() Capabilities { return p.caps } +func (p *builtPlugin) Install(r Registrar) error { + if p.rule != nil { + r.Restrict(p.rule) + } + for _, action := range p.actions { + action(r) + } + return nil +} diff --git a/extension/platform/builder_test.go b/extension/platform/builder_test.go new file mode 100644 index 000000000..541271a1b --- /dev/null +++ b/extension/platform/builder_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +// recorder Registrar captures everything a builder schedules so the +// test can assert what Install produced without involving the host. +type recorder struct { + observers int + wrappers int + lifecycles int + rule *platform.Rule +} + +func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) { + r.observers++ +} +func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ } +func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ } +func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule } + +func TestBuilder_basicAssembly(t *testing.T) { + p, err := platform.NewPlugin("audit", "0.1.0"). + Observer(platform.Before, "pre", platform.All(), + func(context.Context, platform.Invocation) {}). + Observer(platform.After, "post", platform.All(), + func(context.Context, platform.Invocation) {}). + Wrap("policy", platform.All(), + func(next platform.Handler) platform.Handler { return next }). + On(platform.Startup, "boot", + func(context.Context, *platform.LifecycleContext) error { return nil }). + FailOpen(). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + if p.Name() != "audit" || p.Version() != "0.1.0" { + t.Errorf("metadata = %q/%q", p.Name(), p.Version()) + } + if p.Capabilities().FailurePolicy != platform.FailOpen { + t.Errorf("FailurePolicy = %v, want FailOpen", p.Capabilities().FailurePolicy) + } + + r := &recorder{} + if err := p.Install(r); err != nil { + t.Fatalf("Install: %v", err) + } + if r.observers != 2 || r.wrappers != 1 || r.lifecycles != 1 { + t.Errorf("Install dispatch = observers=%d wrappers=%d lifecycles=%d", + r.observers, r.wrappers, r.lifecycles) + } +} + +// Restrict() flips Restricts=true and FailClosed automatically — a +// policy plugin can't accidentally ship under FailOpen. +func TestBuilder_restrictForcesFailClosed(t *testing.T) { + p, err := platform.NewPlugin("policy-plugin", "0.1.0"). + Restrict(&platform.Rule{Name: "read-only", MaxRisk: platform.RiskRead}). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + caps := p.Capabilities() + if !caps.Restricts { + t.Errorf("Restricts = false, want true (Restrict() should flip it)") + } + if caps.FailurePolicy != platform.FailClosed { + t.Errorf("FailurePolicy = %v, want FailClosed (Restrict() implies it)", caps.FailurePolicy) + } + + r := &recorder{} + if err := p.Install(r); err != nil { + t.Fatalf("Install: %v", err) + } + if r.rule == nil || r.rule.Name != "read-only" { + t.Errorf("Install did not propagate Rule: %+v", r.rule) + } +} + +// Invalid name surfaces at Build time, not at NewPlugin. +func TestBuilder_invalidPluginName(t *testing.T) { + _, err := platform.NewPlugin("Has_Underscore_And_Caps", "0.1").Build() + if err == nil { + t.Fatalf("Build must reject malformed plugin name") + } + if !strings.Contains(err.Error(), "invalid plugin name") { + t.Errorf("error should mention plugin name, got: %v", err) + } +} + +// Duplicate hookName within the same builder is rejected. +func TestBuilder_duplicateHookName(t *testing.T) { + noopObs := func(context.Context, platform.Invocation) {} + _, err := platform.NewPlugin("dup", "0"). + Observer(platform.Before, "h", platform.All(), noopObs). + Observer(platform.After, "h", platform.All(), noopObs). + Build() + if err == nil { + t.Fatalf("Build must reject duplicate hookName") + } + if !strings.Contains(err.Error(), "already used") { + t.Errorf("error should mention duplicate hookName, got %v", err) + } +} + +func TestBuilder_invalidHookName(t *testing.T) { + _, err := platform.NewPlugin("p", "0"). + Observer(platform.Before, "Bad.Name", platform.All(), + func(context.Context, platform.Invocation) {}). + Build() + if err == nil { + t.Fatalf("Build must reject hookName with dot") + } +} + +// MustBuild panics on builder error. +func TestBuilder_mustBuildPanicsOnError(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("MustBuild must panic when Build would fail") + } + }() + _ = platform.NewPlugin("BadName", "0").MustBuild() +} + +func TestBuilder_restrictNilRejected(t *testing.T) { + _, err := platform.NewPlugin("p", "0").Restrict(nil).Build() + if err == nil { + t.Fatalf("Restrict(nil) must produce error") + } +} + +func TestBuilder_capabilitiesSetters(t *testing.T) { + p, err := platform.NewPlugin("p", "0.1"). + RequireCLI(">=1.0.0"). + FailClosed(). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + caps := p.Capabilities() + if caps.RequiredCLIVersion != ">=1.0.0" { + t.Errorf("RequiredCLIVersion = %q, want >=1.0.0", caps.RequiredCLIVersion) + } + if caps.FailurePolicy != platform.FailClosed { + t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy) + } +} + +func TestBuilder_restrictThenFailOpenRejected(t *testing.T) { + rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead} + _, err := platform.NewPlugin("p", "0").Restrict(rule).FailOpen().Build() + if err == nil { + t.Fatalf("Build must reject Restrict()+FailOpen() mismatch") + } + if !strings.Contains(err.Error(), "FailClosed") { + t.Errorf("error should mention FailClosed, got: %v", err) + } +} + +// Restrict() flips FailurePolicy to FailClosed; the previous FailOpen() +// is overridden. Pin it so the Build-time validation does not over-reject. +func TestBuilder_failOpenThenRestrictOK(t *testing.T) { + rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead} + p, err := platform.NewPlugin("p", "0").FailOpen().Restrict(rule).Build() + if err != nil { + t.Fatalf("FailOpen()+Restrict() must succeed (Restrict flips to FailClosed): %v", err) + } + if p.Capabilities().FailurePolicy != platform.FailClosed { + t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy) + } +} diff --git a/extension/platform/capabilities.go b/extension/platform/capabilities.go new file mode 100644 index 000000000..fc517c426 --- /dev/null +++ b/extension/platform/capabilities.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// FailurePolicy controls what the framework does when a plugin's install +// stage fails (Capabilities() panics, Install returns error, etc.). +type FailurePolicy int + +const ( + // FailOpen (default) — log a warning and skip THIS plugin; the rest + // of the CLI keeps running. Appropriate for pure-observer plugins + // where missing audit data is preferable to a broken CLI. + FailOpen FailurePolicy = iota + + // FailClosed — abort the entire CLI startup. Required for any + // plugin that contributes Restrict() (a missing policy plugin = + // missing security boundary) or that owns any safety-sensitive + // concern. Enforced by the framework: Capabilities.Restricts=true + // must pair with FailurePolicy=FailClosed. + FailClosed +) + +// Capabilities declares the plugin's self-description. Plugin.Capabilities +// MUST be implemented even when every field would be its zero value -- +// the requirement keeps FailurePolicy / Restricts visible to the author +// at the moment they write the plugin, preventing the "I just want to +// add an audit observer" mistake of accidentally shipping a policy +// plugin with the default FailOpen. +type Capabilities struct { + // RequiredCLIVersion is a semver constraint (e.g. ">=1.1.0"). + // Plugins that need a specific framework feature should declare + // the minimum version they tested against; the host fails the + // install when the running CLI is older. Empty string means "no + // version requirement". + RequiredCLIVersion string + + // Restricts declares whether Install will call r.Restrict(). The + // framework enforces consistency: declaring Restricts=true and + // then NOT calling r.Restrict (or vice versa) aborts the install + // with the `restricts_mismatch` reason_code. This pre-flight + // declaration also lets `config policy show` introspect "which + // plugins are policy plugins" without running them. + Restricts bool + + // FailurePolicy decides what happens on install failure. See the + // constants above; the framework requires FailClosed whenever + // Restricts=true. + FailurePolicy FailurePolicy +} diff --git a/extension/platform/doc.go b/extension/platform/doc.go new file mode 100644 index 000000000..8897876c2 --- /dev/null +++ b/extension/platform/doc.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package platform is the single public extension contract for lark-cli. +// +// External integrators (plugin authors, embedding platforms) only import this +// package; everything else under internal/ is off-limits. +// +// Plugin lifecycle: +// +// - Plugin - the interface every plugin implements (Name / Version / Capabilities / Install) +// - Registrar - what Install receives; the four registration verbs (Observe / Wrap / On / Restrict) +// - Capabilities - declared up front: FailurePolicy (FailOpen | FailClosed) and Restricts +// - Register - process-wide entry point; plugins call this from init() +// +// Hook surface (what Install hangs off Registrar): +// +// - Observer - side-effect-only callback, panic-safe, runs Before / After RunE +// - Wrapper - middleware that can short-circuit via AbortError +// - LifecycleHandler - reacts to Startup / Shutdown / etc. (LifecycleEvent + When) +// - Selector - chooses which commands a hook applies to (ByDomain / ByWrite / ByReadOnly / ByExactRisk / And / Or / Not, etc.) +// - Handler - the inner "run the command" function Wrappers compose around +// - Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / DenialLayer / DenialPolicySource) +// - AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName +// +// Policy surface (what Restrict contributes, also consumable from yaml policy): +// +// - Rule - declarative policy rule (Allow / Deny / MaxRisk / Identities / AllowUnannotated) +// - CommandView - read-only command metadata view (Path / Domain / Risk / Identities) +// - Risk / Identity - defined string types with closed taxonomies; ParseRisk / ParseIdentity +// convert raw strings (yaml, cobra annotation) into typed values; r.Rank() +// gives a comparable rank for the read < write < high-risk-write ordering +// - CommandDeniedError - structured error returned to denied callers +// +// Stability: every exported symbol here is part of the contract. Internal +// orchestration (staging, validation, RunE wrapping, denial guard) lives +// under internal/platform, internal/hook and internal/cmdpolicy and is not +// importable by third parties. +package platform diff --git a/extension/platform/errors.go b/extension/platform/errors.go new file mode 100644 index 000000000..7bd99f2d2 --- /dev/null +++ b/extension/platform/errors.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "fmt" + +// CommandDeniedError is the structured error returned by a denyStub. Every +// pruned-command execution path -- direct invocation, alias expansion, +// internal call -- returns this exact type. It is wire-compatible with the +// output.ExitError envelope via the Layer (== error.type) field and the +// detail map produced by ExitError(). +// +// Layer values: +// +// - "strict_mode" -- credential strict-mode rejected the command +// - "policy" -- user-layer Rule rejected the command +// +// PolicySource is a free-form identifier such as "plugin:secaudit", +// "yaml:mywork", or "strict-mode". Reason fields: +// +// - ReasonCode -- closed enum, see tech-doc 5.3 (e.g. write_not_allowed, +// all_children_denied, identity_not_supported) +// - Reason -- human-readable text +type CommandDeniedError struct { + Path string + Layer string + PolicySource string + RuleName string + ReasonCode string + Reason string +} + +// Error implements the standard error interface. +func (e *CommandDeniedError) Error() string { + if e.Reason != "" { + return fmt.Sprintf("command %q denied: %s", e.Path, e.Reason) + } + return fmt.Sprintf("command %q denied (%s/%s)", e.Path, e.Layer, e.ReasonCode) +} diff --git a/extension/platform/errors_test.go b/extension/platform/errors_test.go new file mode 100644 index 000000000..767e00d89 --- /dev/null +++ b/extension/platform/errors_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +func TestCommandDeniedError_messageFormats(t *testing.T) { + withReason := &platform.CommandDeniedError{ + Path: "docs/+update", + Layer: "policy", + ReasonCode: "write_not_allowed", + Reason: "write disabled by policy", + } + if got := withReason.Error(); got != `command "docs/+update" denied: write disabled by policy` { + t.Fatalf("Error() with Reason = %q", got) + } + + noReason := &platform.CommandDeniedError{ + Path: "docs/+update", + Layer: "strict_mode", + ReasonCode: "identity_not_supported", + } + if got := noReason.Error(); got != `command "docs/+update" denied (strict_mode/identity_not_supported)` { + t.Fatalf("Error() without Reason = %q", got) + } +} + +// errors.As must work so consumers can type-assert without unwrap gymnastics. +func TestCommandDeniedError_satisfiesErrorsAs(t *testing.T) { + var err error = &platform.CommandDeniedError{Path: "x"} + var target *platform.CommandDeniedError + if !errors.As(err, &target) { + t.Fatalf("errors.As should match CommandDeniedError") + } + if target.Path != "x" { + t.Fatalf("target.Path = %q, want %q", target.Path, "x") + } +} diff --git a/extension/platform/example_test.go b/extension/platform/example_test.go new file mode 100644 index 000000000..078398252 --- /dev/null +++ b/extension/platform/example_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/extension/platform" +) + +// ExampleNewPlugin_observer registers an audit Observer that fires +// after every command, regardless of success or failure. +func ExampleNewPlugin_observer() { + p, _ := platform.NewPlugin("audit", "0.1.0"). + Observer(platform.After, "log", platform.All(), + func(ctx context.Context, inv platform.Invocation) { + _ = inv.Cmd().Path() // do something useful with the command + }). + FailOpen(). + Build() + fmt.Println(p.Name(), p.Version()) + // Output: audit 0.1.0 +} + +// ExampleNewPlugin_wrapper registers a Wrap that short-circuits any +// write-class command. The framework converts the returned +// *AbortError into a structured "hook" envelope; observers still +// fire on the After stage so audit sees the attempt. +func ExampleNewPlugin_wrapper() { + p, _ := platform.NewPlugin("policy-plugin", "0.1.0"). + Wrap("block-writes", platform.ByWrite(), + func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + return &platform.AbortError{ + HookName: "block-writes", + Reason: "writes are disabled for this session", + } + } + }). + FailOpen(). + Build() + fmt.Println(p.Capabilities().FailurePolicy == platform.FailOpen) + // Output: true +} + +// ExampleNewPlugin_restrict registers a policy plugin that allows +// only docs/* read commands. Note that Restrict() implicitly sets +// FailClosed — a policy plugin must abort the binary if it fails to +// install, not silently disappear. +func ExampleNewPlugin_restrict() { + p, _ := platform.NewPlugin("readonly-docs", "0.1.0"). + Restrict(&platform.Rule{ + Name: "docs-only", + Allow: []string{"docs/**"}, + MaxRisk: platform.RiskRead, + }). + Build() + caps := p.Capabilities() + fmt.Println(caps.Restricts, caps.FailurePolicy == platform.FailClosed) + // Output: true true +} diff --git a/extension/platform/examples/.gitignore b/extension/platform/examples/.gitignore new file mode 100644 index 000000000..6c34736fb --- /dev/null +++ b/extension/platform/examples/.gitignore @@ -0,0 +1,2 @@ +audit-observer/audit-observer +readonly-policy/readonly-policy diff --git a/extension/platform/examples/README.md b/extension/platform/examples/README.md new file mode 100644 index 000000000..c7eab33d7 --- /dev/null +++ b/extension/platform/examples/README.md @@ -0,0 +1,13 @@ +# lark-cli plugin examples + +Runnable fork-and-blank-import examples that demonstrate the Plugin +SDK in production-shape. Each subdirectory is a complete `main` +package: `go build .` produces a working CLI. + +| Example | What it shows | +| --- | --- | +| [audit-observer](./audit-observer/) | Simplest possible plugin: one Observer matching every command, logs to stderr. | +| [readonly-policy](./readonly-policy/) | Policy plugin: `Restrict()` with `MaxRisk=read`, demonstrates the `FailClosed` + `Restricts=true` auto-pairing. | + +All examples are built by CI (`make examples-build`) so they cannot +silently drift from the SDK. diff --git a/extension/platform/examples/audit-observer/README.md b/extension/platform/examples/audit-observer/README.md new file mode 100644 index 000000000..a860a4dd9 --- /dev/null +++ b/extension/platform/examples/audit-observer/README.md @@ -0,0 +1,26 @@ +# Example: audit observer + +The simplest possible lark-cli plugin: one After observer that logs +every dispatched command to stderr (success or failure). + +## Build & run + +```sh +cd extension/platform/examples/audit-observer +go build -o audit-cli . +./audit-cli config plugins show +# {"plugins":[{"name":"audit", ...}], "total":1} + +./audit-cli api GET /open-apis/contact/v3/users/me +# [audit] api ok (on stderr) +``` + +## Key points + +- `platform.NewPlugin(...).MustBuild()` from `init()`. The blank + import of this package in `main.go` triggers `init()`. +- `Observer(platform.After, ...)` runs **after** the command's RunE, + even on failure (Observers cannot prevent execution). +- `FailOpen()` means: if Install ever fails, the binary logs a + warning and continues without this plugin. Right default for + audit-only plugins. diff --git a/extension/platform/examples/audit-observer/main.go b/extension/platform/examples/audit-observer/main.go new file mode 100644 index 000000000..2c3c30534 --- /dev/null +++ b/extension/platform/examples/audit-observer/main.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command audit-observer is a runnable fork of lark-cli that logs +// every dispatched command to stderr. Demonstrates the simplest +// possible plugin: one After observer matching All commands. +// +// Build & run: +// +// cd extension/platform/examples/audit-observer +// go build -o audit-cli . +// ./audit-cli config plugins show # see "audit" in the list +// ./audit-cli api GET /open-apis/... # observer logs to stderr +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("audit", "0.1.0"). + Observer(platform.After, "log", platform.All(), + func(ctx context.Context, inv platform.Invocation) { + path := inv.Cmd().Path() + if err := inv.Err(); err != nil { + fmt.Fprintf(os.Stderr, "[audit] %s FAILED: %v\n", path, err) + } else { + log.Printf("[audit] %s ok", path) + } + }). + FailOpen(). + MustBuild()) +} + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/extension/platform/examples/readonly-policy/README.md b/extension/platform/examples/readonly-policy/README.md new file mode 100644 index 000000000..9c0963fba --- /dev/null +++ b/extension/platform/examples/readonly-policy/README.md @@ -0,0 +1,61 @@ +# Example: read-only policy + +A policy plugin that installs a `Rule` allowing only `docs/*` and +`im/*` read commands. Any write command produces a structured +`command_denied` envelope. + +## Build & run + +```sh +cd extension/platform/examples/readonly-policy +go build -o readonly-cli . + +./readonly-cli config policy show +# { +# "source": "plugin", +# "source_name": "readonly", +# "denied_paths": N, +# "rule": { +# "name": "agent-readonly", +# "allow": ["docs/**", "im/**"], +# "deny": [], +# "max_risk": "read", +# "identities": [], +# "allow_unannotated": false +# } +# } + +./readonly-cli docs +update --doc-token X --content Y +# {"ok":false,"error":{ +# "type":"command_denied", +# "detail":{ +# "layer":"policy", +# "policy_source":"plugin:readonly", +# "rule_name":"agent-readonly", +# "reason_code":"write_not_allowed" +# } +# }} + +./readonly-cli docs +fetch --doc-token X +# Normal read response (assuming credentials) +``` + +## Key points + +- `Restrict(&Rule{...})` is the only call needed — the Builder + flips Capabilities to `Restricts=true, FailurePolicy=FailClosed` + automatically. A policy plugin that silently fails to install + would erase the security boundary, so FailClosed is enforced. +- `MaxRisk: platform.RiskRead` rejects any command annotated + write / high-risk-write. +- `AllowUnannotated` is left default (false): unannotated commands + are denied with `risk_not_annotated`. Set it to true if you need + a gradual-adoption window for the lark-cli main tree. + +## Caveats + +- A binary may have **only one** plugin calling `Restrict()`. Two + policy plugins is a deliberate `plugin_conflict` configuration + error. +- This Rule shadows any `~/.lark-cli/policy.yml` — plugin Rule + wins per the resolver precedence. diff --git a/extension/platform/examples/readonly-policy/main.go b/extension/platform/examples/readonly-policy/main.go new file mode 100644 index 000000000..21b674bdc --- /dev/null +++ b/extension/platform/examples/readonly-policy/main.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command readonly-policy is a runnable fork of lark-cli that +// installs a Rule permitting only docs/* and im/* read commands. +// Any write command produces a structured command_denied envelope. +// +// Build & run: +// +// cd extension/platform/examples/readonly-policy +// go build -o readonly-cli . +// ./readonly-cli docs +update --doc-token X --content Y +// # {"ok":false,"error":{"type":"command_denied", ...}} +// +// ./readonly-cli config policy show +// # shows the active Rule with source=plugin:readonly +package main + +import ( + "os" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("readonly", "0.1.0"). + Restrict(&platform.Rule{ + Name: "agent-readonly", + Description: "Only read-class docs/im commands. Suitable for AI-agent sessions.", + Allow: []string{"docs/**", "im/**"}, + MaxRisk: platform.RiskRead, + // AllowUnannotated stays default false (fail-closed): + // unannotated commands are denied, surfacing missing + // risk_level annotations early in adoption. + }). + MustBuild()) + // Note: Restrict() implicitly sets Restricts=true and FailClosed. + // No need to call FailClosed() explicitly. +} + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/extension/platform/handler.go b/extension/platform/handler.go new file mode 100644 index 000000000..c08635962 --- /dev/null +++ b/extension/platform/handler.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "context" + +// Handler is the inner function shape every Wrapper composes. It IS the +// "command business logic" from the Wrapper's perspective -- calling +// next(ctx, inv) inside a Wrapper means "let the command proceed"; +// returning early without calling next short-circuits. +type Handler func(ctx context.Context, inv Invocation) error + +// Observer is a side-effect-only command hook. No return value, no +// next-chain control: an Observer can read Invocation but cannot prevent +// the command from running. Used for audit, metrics, and completion +// logs. After-stage Observers fire even when the command failed +// (Invocation.Err() is populated in that case). +type Observer func(ctx context.Context, inv Invocation) + +// Wrapper is a middleware-style hook: it receives the rest of the +// handler chain and returns a wrapped version. The Wrapper decides +// whether to call next (allow), abstain (deny, return an AbortError), +// or transform the result. Multiple Wrappers compose left-to-right by +// registration order; the outermost runs first. +// +// ⚠️ IMPORTANT: The factory function `func(next Handler) Handler` is +// invoked ONCE PER COMMAND DISPATCH, not once at plugin install. This +// lets the framework recover from a panicking factory and convert it +// to a structured envelope, but it means any state captured by the +// outer closure is rebuilt on every command. Long-lived state (HTTP +// clients, caches, metrics counters) MUST live on the Plugin struct +// or in package-level variables, never in factory-local captures. +type Wrapper func(next Handler) Handler + +// LifecycleHandler runs at one of the process-level LifecycleEvent +// slots. The handler may use ctx for cancellation; in the Shutdown +// case the framework supplies a context with a 2-second hard deadline. +type LifecycleHandler func(ctx context.Context, lc *LifecycleContext) error diff --git a/extension/platform/identity.go b/extension/platform/identity.go new file mode 100644 index 000000000..1354f37dd --- /dev/null +++ b/extension/platform/identity.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "fmt" + +// Identity is the identity taxonomy a command supports. +// +// Defined type (not alias) so plugin authors get compile-time + +// IDE help; raw-string boundaries (yaml, cobra annotation) cross +// through ParseIdentity. +type Identity string + +const ( + IdentityUser Identity = "user" + IdentityBot Identity = "bot" +) + +// ParseIdentity converts a raw string into an Identity. Returns +// ("", nil) for empty input ("not specified"), error for unrecognised +// values. Matching is strict (case-sensitive, no trim). +func ParseIdentity(s string) (Identity, error) { + if s == "" { + return "", nil + } + id := Identity(s) + if id != IdentityUser && id != IdentityBot { + return "", fmt.Errorf("invalid identity %q: must be user|bot", s) + } + return id, nil +} + +// IsValid reports whether i is one of the two recognised values. +func (i Identity) IsValid() bool { + return i == IdentityUser || i == IdentityBot +} + +// String returns the underlying string. +func (i Identity) String() string { return string(i) } diff --git a/extension/platform/invocation.go b/extension/platform/invocation.go new file mode 100644 index 000000000..33377558c --- /dev/null +++ b/extension/platform/invocation.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "time" + +// Invocation is the per-command data a Wrapper / Observer receives. It +// is a read-only interface: the framework implementation lives in +// internal/hook and is never visible to plugins, so plugin code cannot +// mutate denial state. +// +// The interface is deliberately NOT a context.Context — it is data only, +// no cancellation. ctx (from the handler signature) carries +// cancellation / timeout / trace propagation. +// +// Accessor semantics: +// +// - Cmd / Args / Started are populated before the first hook fires +// - Err is populated for After observers and the post-next portion of +// a Wrapper (the value the wrapped handler returned) +// - DeniedByPolicy / DenialLayer / DenialPolicySource are populated by +// the framework's denial guard before any hook runs +type Invocation interface { + // Cmd returns the read-only metadata view of the dispatched command. + Cmd() CommandView + + // Args returns a fresh copy of the positional args. + Args() []string + + // Started is the wall-clock time the outermost RunE wrapper began. + Started() time.Time + + // Err is the error the wrapped handler returned. Populated for + // After observers and the post-next portion of a Wrapper. nil + // before the handler runs. + Err() error + + // DeniedByPolicy reports whether the command was rejected by either + // strict-mode or user-layer policy before the chain reached the + // hook. Observers fire even for denied commands (audit case); Wrap + // is physically isolated by the framework so plugins do not need + // to check this themselves before calling next. + DeniedByPolicy() bool + + // DenialLayer returns the layer that rejected the command: + // + // "" - not denied + // "strict_mode" - credential strict-mode + // "policy" - user-layer Rule (Plugin.Restrict() or yaml) + DenialLayer() string + + // DenialPolicySource returns the specific source identifier + // ("plugin:secaudit", "yaml", "strict-mode"). Empty when not denied. + DenialPolicySource() string +} diff --git a/extension/platform/lifecycle.go b/extension/platform/lifecycle.go new file mode 100644 index 000000000..63a05487b --- /dev/null +++ b/extension/platform/lifecycle.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// When selects the temporal slot for command-level Observer hooks. The +// framework wraps every command's RunE so both stages always fire, even +// when RunE itself returns an error (After is failure-safe). +type When int + +const ( + // Before fires immediately before the command's business logic. + Before When = iota + + // After fires after the command's business logic (or its denyStub + // in the denied path). Always fires, even when RunE returned an + // error; Invocation.Err is populated in that case. + After +) + +// LifecycleEvent selects the temporal slot for Lifecycle hooks. These are +// process-level events that fire once per binary execution, not per +// command. Only Startup and Shutdown are defined: additional bootstrap +// phases can be added later as a non-breaking addition if a concrete +// consumer surfaces. +type LifecycleEvent int + +const ( + // Startup fires after plugin install has committed; Plugin.On + // handlers for Startup are guaranteed to be registered before this + // event is emitted (so they can receive it). + Startup LifecycleEvent = iota + + // Shutdown fires once before the process exits. Handler total + // execution is bounded by a hard 2s timeout to prevent a + // misbehaving handler from holding up exit. + Shutdown +) + +// LifecycleContext is passed to LifecycleHandler. Err is the error from +// the preceding command (when Event == Shutdown after a failed RunE); +// otherwise nil. +type LifecycleContext struct { + Event LifecycleEvent + Err error +} diff --git a/extension/platform/plugin.go b/extension/platform/plugin.go new file mode 100644 index 000000000..303f677b5 --- /dev/null +++ b/extension/platform/plugin.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// Plugin is the single contract a third-party / embedding integrator +// implements to extend lark-cli. Four methods, every one mandatory. +// +// Name must match the grammar ^[a-z0-9][a-z0-9-]*$. The "." character +// is forbidden so plugin-name + hookName namespacing never produces +// ambiguous joins. +// +// Capabilities must be implemented even when every field is zero. The +// requirement is deliberate: it keeps FailurePolicy / Restricts in the +// author's eyeline. +// +// Install runs once during the Bootstrap pipeline. The plugin uses the +// supplied Registrar to register hooks and (optionally) a Rule. Errors +// returned from Install honour the plugin's Capabilities.FailurePolicy +// (fail-open warns + skips this plugin; fail-closed aborts the CLI). +type Plugin interface { + Name() string + Version() string + Capabilities() Capabilities + Install(r Registrar) error +} diff --git a/extension/platform/register.go b/extension/platform/register.go new file mode 100644 index 000000000..fe22059dc --- /dev/null +++ b/extension/platform/register.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "sync" + +// Register adds a plugin to the global registry. Plugins call this from +// init() (typically through a blank import in the embedder's main). +// +// Register is intentionally tolerant of malformed input: validation +// happens later in the host's InstallAll phase, where errors can be +// surfaced through the typed plugin_install envelope. Register itself +// never panics so that init-time problems do not crash the binary +// before main has a chance to install its recover-and-envelope logic. +// +// The registry holds plugins in insertion order so InstallAll can +// process them deterministically. +func Register(p Plugin) { + pluginRegistry.add(p) +} + +// RegisteredPlugins returns a snapshot of the global plugin registry. +// Order matches Register insertion. The host reads this once during +// InstallAll. +func RegisteredPlugins() []Plugin { + return pluginRegistry.snapshot() +} + +// pluginRegistry is the package-level singleton. The mutex protects +// concurrent Register calls -- harmless in practice (init runs +// serially) but cheap insurance. +var pluginRegistry = ®istry{} + +type registry struct { + mu sync.Mutex + plugins []Plugin +} + +func (r *registry) add(p Plugin) { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins = append(r.plugins, p) +} + +func (r *registry) snapshot() []Plugin { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]Plugin, len(r.plugins)) + copy(out, r.plugins) + return out +} + +func (r *registry) reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins = nil +} diff --git a/extension/platform/register_test.go b/extension/platform/register_test.go new file mode 100644 index 000000000..80425e701 --- /dev/null +++ b/extension/platform/register_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +type stubPlugin struct{ name string } + +func (s stubPlugin) Name() string { return s.name } +func (s stubPlugin) Version() string { return "0.0.1" } +func (s stubPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} } +func (s stubPlugin) Install(platform.Registrar) error { return nil } + +// Tests should always reset the global registry to keep them +// independent. Verifies the reset hook is functional. +func TestRegister_preservesInsertionOrder(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + platform.Register(stubPlugin{name: "a"}) + platform.Register(stubPlugin{name: "b"}) + platform.Register(stubPlugin{name: "c"}) + + got := platform.RegisteredPlugins() + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("got %d plugins, want %d", len(got), len(want)) + } + for i, p := range got { + if p.Name() != want[i] { + t.Errorf("plugins[%d] = %q, want %q", i, p.Name(), want[i]) + } + } +} + +func TestRegister_resetClears(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + platform.Register(stubPlugin{name: "a"}) + if len(platform.RegisteredPlugins()) != 1 { + t.Fatalf("expected 1 plugin") + } + platform.ResetForTesting() + if len(platform.RegisteredPlugins()) != 0 { + t.Fatalf("expected reset to clear") + } +} diff --git a/extension/platform/register_testing.go b/extension/platform/register_testing.go new file mode 100644 index 000000000..8d32f67f0 --- /dev/null +++ b/extension/platform/register_testing.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// ResetForTesting clears the global plugin registry. Exposed for test +// isolation only — plugin authors and SDK consumers must NOT call this +// from production code. The function is exported (rather than placed in +// an internal test-only file) so that `go test ./...` works for every +// downstream package without an extra build tag. +// +// Tests that exercise plugin registration must defer +// `t.Cleanup(platform.ResetForTesting)` so subsequent tests start from a +// clean slate. The helper is NOT goroutine-safe across concurrent +// `t.Parallel()` tests — the global registry is shared process state. +func ResetForTesting() { pluginRegistry.reset() } diff --git a/extension/platform/registrar.go b/extension/platform/registrar.go new file mode 100644 index 000000000..8774050bf --- /dev/null +++ b/extension/platform/registrar.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// Registrar is the imperative API a plugin uses inside its Install +// method to wire up hooks and rules. The framework provides a staging +// implementation that buffers calls and commits them atomically when +// Install returns nil; failure rolls everything back. +// +// hookName must match the grammar ^[a-z0-9][a-z0-9-]*$ (no dots). The +// framework prepends the plugin's Name() with a dot so the global hook +// identifier is "{plugin}.{hook}". A plugin cannot register two hooks +// with the same name in the same Install call. +// +// Restrict may be called at most once per plugin; multiple plugins +// contributing Restrict() is a configuration error (the resolver +// aborts startup). +type Registrar interface { + // Observe registers a side-effect-only command hook at the given + // When stage. The selector decides which commands it fires on. + Observe(when When, hookName string, sel Selector, fn Observer) + + // Wrap registers a middleware-style command hook. The Wrap chain + // composes left-to-right in registration order; the outermost + // Wrapper runs first. + Wrap(hookName string, sel Selector, w Wrapper) + + // On registers a lifecycle handler for the given event. + On(event LifecycleEvent, hookName string, fn LifecycleHandler) + + // Restrict contributes a pruning Rule. The framework merges it + // with the yaml-sourced Rule using single-rule semantics: plugin + // rule wins, but two plugins both calling Restrict abort startup. + Restrict(r *Rule) +} diff --git a/extension/platform/risk.go b/extension/platform/risk.go new file mode 100644 index 000000000..287c5ff8a --- /dev/null +++ b/extension/platform/risk.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "fmt" + +// Risk is the three-tier risk taxonomy declared on every command. +// +// A defined type (not an alias of string) so plugin authors get +// compile-time + IDE candidate help when passing the constants below. +// Crossing the string boundary (yaml, cobra annotation) goes through +// ParseRisk so typos surface as `risk_invalid` rather than silently +// flowing through. +type Risk string + +const ( + RiskRead Risk = "read" + RiskWrite Risk = "write" + RiskHighRiskWrite Risk = "high-risk-write" +) + +// riskOrder maps the Risk taxonomy to a comparable rank. The pruning +// engine compares ranks for the MaxRisk axis. +var riskOrder = map[Risk]int{ + RiskRead: 0, + RiskWrite: 1, + RiskHighRiskWrite: 2, +} + +// ParseRisk converts a raw string (yaml, cobra annotation) into a Risk. +// +// - s == "" → ("", nil) "not specified" +// - s 在闭合枚举 → (Risk(s), nil) OK +// - s 不在枚举内 → ("", error) invalid +// +// The (absent vs invalid) split mirrors the cmdpolicy engine's +// risk_not_annotated vs risk_invalid reason codes — callers can treat +// the "" + nil case as "not specified" without losing the distinction +// from a typo. +// +// Matching is strict: "Read" / "READ" / " read " are all rejected. +// annotation is developer code, not user input — strict matching is +// the typo-catch mechanism, not a normalisation opportunity. +func ParseRisk(s string) (Risk, error) { + if s == "" { + return "", nil + } + r := Risk(s) + if _, ok := riskOrder[r]; !ok { + return "", fmt.Errorf("invalid risk %q: must be read|write|high-risk-write", s) + } + return r, nil +} + +// IsValid reports whether r is one of the three recognised values. +func (r Risk) IsValid() bool { + _, ok := riskOrder[r] + return ok +} + +// Rank returns the comparable rank of r. ok=false when r is not in the +// closed taxonomy. +func (r Risk) Rank() (rank int, ok bool) { + rank, ok = riskOrder[r] + return rank, ok +} + +// String returns the underlying string. Useful for yaml/json output +// and cobra annotation injection. +func (r Risk) String() string { return string(r) } diff --git a/extension/platform/risk_test.go b/extension/platform/risk_test.go new file mode 100644 index 000000000..d934a03c5 --- /dev/null +++ b/extension/platform/risk_test.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +func TestRisk_Rank_orderedTaxonomy(t *testing.T) { + cases := []struct { + level platform.Risk + want int + }{ + {platform.RiskRead, 0}, + {platform.RiskWrite, 1}, + {platform.RiskHighRiskWrite, 2}, + } + for _, c := range cases { + got, ok := c.level.Rank() + if !ok || got != c.want { + t.Errorf("Risk(%q).Rank() = (%d,%v), want (%d,true)", c.level, got, ok, c.want) + } + } + + if _, ok := platform.Risk("unknown-level").Rank(); ok { + t.Fatalf("unknown-level.Rank() ok should be false") + } + if _, ok := platform.Risk("").Rank(); ok { + t.Fatalf("empty.Rank() ok should be false (signals 'no risk annotation')") + } +} + +// The Risk ordering must be strict: read < write < high-risk-write. The +// policy engine compares ranks; a regression that swaps the order would +// silently let high-risk commands pass under MaxRisk=write. +func TestRisk_Rank_strictlyMonotonic(t *testing.T) { + r1, _ := platform.RiskRead.Rank() + r2, _ := platform.RiskWrite.Rank() + r3, _ := platform.RiskHighRiskWrite.Rank() + if !(r1 < r2 && r2 < r3) { + t.Fatalf("Risk ranks not monotonic: read=%d write=%d high=%d", r1, r2, r3) + } +} + +func TestRisk_IsValid(t *testing.T) { + valid := []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite} + for _, r := range valid { + if !r.IsValid() { + t.Errorf("%q.IsValid() = false, want true", r) + } + } + invalid := []platform.Risk{"", "wrtie", "Read", "READ", " read "} + for _, r := range invalid { + if r.IsValid() { + t.Errorf("%q.IsValid() = true, want false", r) + } + } +} + +// ParseRisk distinguishes absent (empty input) from invalid (typo). +// The absent / invalid split mirrors the cmdpolicy engine's +// risk_not_annotated vs risk_invalid reason codes. +func TestParseRisk(t *testing.T) { + // Empty -> ("", nil) — "not specified" + got, err := platform.ParseRisk("") + if err != nil || got != "" { + t.Errorf(`ParseRisk("") = (%q,%v), want ("",nil)`, got, err) + } + + // Valid values pass through + for _, want := range []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite} { + got, err := platform.ParseRisk(string(want)) + if err != nil || got != want { + t.Errorf("ParseRisk(%q) = (%q,%v), want (%q,nil)", want, got, err, want) + } + } + + // Typo -> error, strict matching (case-sensitive, no trim) + bad := []string{"wrtie", "Read", "READ", " read ", "high_risk_write"} + for _, s := range bad { + got, err := platform.ParseRisk(s) + if err == nil { + t.Errorf("ParseRisk(%q) succeeded (got %q), want error", s, got) + } + if got != "" { + t.Errorf("ParseRisk(%q) returned %q, want empty Risk on error", s, got) + } + } +} + +func TestParseIdentity(t *testing.T) { + got, err := platform.ParseIdentity("") + if err != nil || got != "" { + t.Errorf(`ParseIdentity("") = (%q,%v), want ("",nil)`, got, err) + } + for _, want := range []platform.Identity{platform.IdentityUser, platform.IdentityBot} { + got, err := platform.ParseIdentity(string(want)) + if err != nil || got != want { + t.Errorf("ParseIdentity(%q) = (%q,%v)", want, got, err) + } + } + if _, err := platform.ParseIdentity("admin"); err == nil { + t.Fatalf(`ParseIdentity("admin") want error`) + } +} + +func TestIdentity_IsValid(t *testing.T) { + if !platform.IdentityUser.IsValid() { + t.Error("user.IsValid() = false") + } + if !platform.IdentityBot.IsValid() { + t.Error("bot.IsValid() = false") + } + if platform.Identity("admin").IsValid() { + t.Error("admin.IsValid() = true") + } +} diff --git a/extension/platform/rule.go b/extension/platform/rule.go new file mode 100644 index 000000000..cf5ecebaf --- /dev/null +++ b/extension/platform/rule.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// Rule is the declarative policy rule data structure. yaml files and +// Plugin.Restrict() both produce the same Rule. +// +// At any moment there is at most one effective Rule -- the resolver decides +// which source wins (Plugin > yaml > none). This package only defines the +// shape; selection lives in internal/cmdpolicy. +// +// The four filter fields are joined by AND. See the engine's Evaluate for +// the full semantics. JSON tags are used by `config policy show`; yaml +// parsing lives in internal/cmdpolicy/yaml so the public API does not +// depend on a yaml library. +type Rule struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + + // Allow is a list of doublestar globs (slash-separated paths). An empty + // slice means "no path restriction"; a non-empty slice means "command + // path must match at least one glob". + Allow []string `json:"allow,omitempty"` + + // Deny is a list of doublestar globs. A path that matches any Deny glob + // is rejected regardless of Allow. + Deny []string `json:"deny,omitempty"` + + // MaxRisk is the highest allowed risk level (inclusive). Empty string + // means "no risk restriction". Comparison uses the closed taxonomy + // read < write < high-risk-write. + MaxRisk Risk `json:"max_risk,omitempty"` + + // Identities is the allowed identity whitelist. A command passes when + // the intersection with the command's own supported identities is + // non-empty. Empty slice means "no identity restriction". + Identities []Identity `json:"identities,omitempty"` + + // AllowUnannotated controls how commands missing a risk_level + // annotation are handled when this Rule is active. + // + // Default (false, fail-closed): unannotated commands are rejected + // with reason_code=risk_not_annotated. This is the safe default + // — a typo'd or forgotten annotation cannot slip past an + // "agent read-only" rule. + // + // Set to true to opt out during gradual adoption: lark-cli main + // has hundreds of service commands that may not yet carry + // risk_level annotations, and a brand-new policy plugin would + // otherwise lock the binary to nothing. + // + // This flag does NOT affect risk_invalid (typos): a command that + // claims a risk but mis-spells it is always denied, regardless of + // AllowUnannotated. Typo is a code bug, not a migration phase. + // + // No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so + // platform stays free of a yaml library dependency. + AllowUnannotated bool `json:"allow_unannotated,omitempty"` +} diff --git a/extension/platform/selector.go b/extension/platform/selector.go new file mode 100644 index 000000000..0e632537f --- /dev/null +++ b/extension/platform/selector.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "github.com/bmatcuk/doublestar/v4" + +// Selector picks the commands a hook fires on. A nil Selector is +// equivalent to None() -- safer than an "always-match" default because +// it forces every hook to declare its scope explicitly. Compose +// selectors with And / Or / Not. +type Selector func(cmd CommandView) bool + +// All matches every command. Use for audit / metrics observers that +// must run on the whole surface. +func All() Selector { return func(CommandView) bool { return true } } + +// None matches no command. Useful as a "disabled" placeholder. +func None() Selector { return func(CommandView) bool { return false } } + +// ByDomain matches a command whose Domain() is one of the supplied +// names. Commands with unknown (empty-string) Domain never match this +// selector -- the caller should pair it with a Selector that handles +// unknown explicitly when that case matters. +func ByDomain(domains ...string) Selector { + wanted := newStringSet(domains) + return func(cmd CommandView) bool { + d := cmd.Domain() + return d != "" && wanted[d] + } +} + +// ByCommandPath matches against the canonical slash-form path. Patterns +// are doublestar globs ("docs/+update", "im/*", "**"). Invalid patterns +// never match; ValidateRule's twin check catches them at the source. +func ByCommandPath(patterns ...string) Selector { + return func(cmd CommandView) bool { + path := cmd.Path() + for _, p := range patterns { + if ok, err := doublestar.Match(p, path); err == nil && ok { + return true + } + } + return false + } +} + +// ByIdentity matches when the command's supported identities include +// the supplied id. Unknown identities never match. +func ByIdentity(id Identity) Selector { + return func(cmd CommandView) bool { + for _, x := range cmd.Identities() { + if x == id { + return true + } + } + return false + } +} + +// Risk-based selectors below match only commands whose declared risk +// equals the selector's target level. The closed taxonomy is read / +// write / high-risk-write — there is no "unknown" branch in the public +// API. When a Rule without AllowUnannotated=true is registered, the +// policy engine treats unannotated commands as implicit deny, so risk- +// based selectors never see them in hook dispatch under that +// configuration. + +// ByExactRisk matches commands whose declared risk level is exactly level. +func ByExactRisk(level Risk) Selector { + return func(cmd CommandView) bool { + v, ok := cmd.Risk() + return ok && v == level + } +} + +// ByWrite matches commands whose risk is "write" or "high-risk-write". +func ByWrite() Selector { + return func(cmd CommandView) bool { + v, ok := cmd.Risk() + return ok && (v == RiskWrite || v == RiskHighRiskWrite) + } +} + +// ByReadOnly matches commands whose risk is "read". +func ByReadOnly() Selector { + return func(cmd CommandView) bool { + v, ok := cmd.Risk() + return ok && v == RiskRead + } +} + +// normalize maps a nil Selector to None() so combinators honour the +// "nil == None()" contract documented on the Selector type. +func normalize(s Selector) Selector { + if s == nil { + return None() + } + return s +} + +// And composes selectors with AND semantics. +func (s Selector) And(other Selector) Selector { + left, right := normalize(s), normalize(other) + return func(cmd CommandView) bool { + return left(cmd) && right(cmd) + } +} + +// Or composes selectors with OR semantics. +func (s Selector) Or(other Selector) Selector { + left, right := normalize(s), normalize(other) + return func(cmd CommandView) bool { + return left(cmd) || right(cmd) + } +} + +// Not negates the selector. A nil receiver is treated as None(), so +// nil.Not() behaves as All(). +func (s Selector) Not() Selector { + inner := normalize(s) + return func(cmd CommandView) bool { + return !inner(cmd) + } +} + +func newStringSet(items []string) map[string]bool { + out := make(map[string]bool, len(items)) + for _, x := range items { + out[x] = true + } + return out +} diff --git a/extension/platform/selector_test.go b/extension/platform/selector_test.go new file mode 100644 index 000000000..f08b0c660 --- /dev/null +++ b/extension/platform/selector_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +// fakeView is a minimal CommandView for unit-testing selectors. +type fakeView struct { + path string + domain string + risk string + riskOK bool + identities []string +} + +func (v fakeView) Path() string { return v.path } +func (v fakeView) Domain() string { return v.domain } +func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.riskOK } +func (v fakeView) Identities() []platform.Identity { + out := make([]platform.Identity, len(v.identities)) + for i, x := range v.identities { + out[i] = platform.Identity(x) + } + return out +} +func (v fakeView) Annotation(key string) (string, bool) { return "", false } + +func TestAll_None(t *testing.T) { + cmd := fakeView{} + if !platform.All()(cmd) { + t.Errorf("All() must match every command") + } + if platform.None()(cmd) { + t.Errorf("None() must match no command") + } +} + +func TestByDomain(t *testing.T) { + sel := platform.ByDomain("docs", "im") + if !sel(fakeView{domain: "docs"}) { + t.Errorf("docs should match") + } + if sel(fakeView{domain: "vc"}) { + t.Errorf("vc must not match docs/im selector") + } + // Unknown domain (empty) must not match. + if sel(fakeView{domain: ""}) { + t.Errorf("unknown domain must not match ByDomain (use ByDomainOrUnknown style if desired)") + } +} + +// Risk-based selectors match only against the closed taxonomy +// (read / write / high-risk-write). Commands without a risk annotation +// never match; the policy engine guarantees such commands cannot reach +// hook dispatch when a Rule without AllowUnannotated=true is registered. +func TestByExactRisk_unknownDoesNotMatch(t *testing.T) { + sel := platform.ByExactRisk("write") + if !sel(fakeView{risk: "write", riskOK: true}) { + t.Errorf("exact write should match") + } + if sel(fakeView{riskOK: false}) { + t.Errorf("unknown must not match ByExactRisk") + } + if sel(fakeView{risk: "read", riskOK: true}) { + t.Errorf("read must not match ByExactRisk(write)") + } +} + +func TestByWrite_byReadOnly(t *testing.T) { + if !platform.ByWrite()(fakeView{risk: "write", riskOK: true}) { + t.Errorf("write should match ByWrite") + } + if !platform.ByWrite()(fakeView{risk: "high-risk-write", riskOK: true}) { + t.Errorf("high-risk-write should match ByWrite") + } + if platform.ByWrite()(fakeView{risk: "read", riskOK: true}) { + t.Errorf("read must not match ByWrite") + } + if platform.ByWrite()(fakeView{riskOK: false}) { + t.Errorf("unknown must not match ByWrite") + } + if !platform.ByReadOnly()(fakeView{risk: "read", riskOK: true}) { + t.Errorf("read should match ByReadOnly") + } + if platform.ByReadOnly()(fakeView{riskOK: false}) { + t.Errorf("unknown must not match ByReadOnly") + } +} + +func TestByCommandPath(t *testing.T) { + sel := platform.ByCommandPath("docs/**", "im/+send") + if !sel(fakeView{path: "docs/+update"}) { + t.Errorf("docs/+update should match docs/**") + } + if !sel(fakeView{path: "im/+send"}) { + t.Errorf("im/+send should match") + } + if sel(fakeView{path: "contact/+search"}) { + t.Errorf("contact/+search must not match") + } +} + +func TestByIdentity(t *testing.T) { + sel := platform.ByIdentity("bot") + if !sel(fakeView{identities: []string{"user", "bot"}}) { + t.Errorf("ids containing bot should match") + } + if sel(fakeView{identities: []string{"user"}}) { + t.Errorf("user-only ids must not match bot selector") + } +} + +func TestSelector_AndOrNot(t *testing.T) { + docsAndWrite := platform.ByDomain("docs").And(platform.ByExactRisk("write")) + if !docsAndWrite(fakeView{domain: "docs", risk: "write", riskOK: true}) { + t.Errorf("AND of matching selectors should match") + } + if docsAndWrite(fakeView{domain: "docs", risk: "read", riskOK: true}) { + t.Errorf("AND fails when one side fails") + } + + docsOrIm := platform.ByDomain("docs").Or(platform.ByDomain("im")) + if !docsOrIm(fakeView{domain: "im"}) { + t.Errorf("OR should match either side") + } + + notRead := platform.ByReadOnly().Not() + if notRead(fakeView{risk: "read", riskOK: true}) { + t.Errorf("Not(ByReadOnly) must reject read commands") + } + if !notRead(fakeView{risk: "write", riskOK: true}) { + t.Errorf("Not(ByReadOnly) should match write") + } +} + +func TestSelector_NilSafeWhenComposed(t *testing.T) { + // A nil Selector is equivalent to None() per the Selector godoc. + // Composition must honour that contract: the resulting selector + // must not panic when invoked and must produce the documented + // boolean outcome (nil-as-None propagates through AND/OR/NOT). + var s platform.Selector + cmd := fakeView{domain: "docs"} + + if got := s.And(platform.All())(cmd); got { + t.Errorf("nil.And(All) should match None semantics (false), got true") + } + if got := s.Or(platform.All())(cmd); !got { + t.Errorf("nil.Or(All) should match (true), got false") + } + if got := platform.All().And(s)(cmd); got { + t.Errorf("All.And(nil) should be None (false), got true") + } + if got := s.Not()(cmd); !got { + t.Errorf("(nil).Not() should be Not(None) = true, got false") + } +} diff --git a/extension/platform/view.go b/extension/platform/view.go new file mode 100644 index 000000000..f7ef3e885 --- /dev/null +++ b/extension/platform/view.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// CommandView is the read-only view of a cobra.Command exposed to plugins +// and the policy engine. *cobra.Command is deliberately NOT reachable +// through this interface -- a plugin should never mutate the command tree. +// +// View semantics: +// +// - The view is a live proxy over the underlying *cobra.Command and its +// annotation chain. Strict-mode replaces nodes via RemoveCommand+ +// AddCommand; the replacement stub explicitly carries the original +// command's annotations and help text forward so audit / compliance +// observers still see Risk / Identities / Domain after a denial. +// User-layer policy mutates in place, so its denyStubs preserve the +// original metadata by construction. +// +// - Path() is the canonical slash form ("docs/+fetch"), matching the +// doublestar glob semantics used by Rule.Allow / Rule.Deny. +// +// - Risk() returns ok=false when the command is unannotated. The policy +// engine treats an unannotated command as implicit deny whenever any +// Rule without AllowUnannotated=true is registered, so risk-based +// Selectors never see unannotated commands during normal hook dispatch +// under that configuration. +type CommandView interface { + // Path is the canonical slash-separated path, rootless ("docs/+update"). + Path() string + + // Domain returns the business domain ("docs", "im", "") inherited from + // the nearest ancestor with a cmdmeta.domain annotation. Empty string + // when no ancestor declares one. + Domain() string + + // Risk returns the static risk level. ok=false signals "no risk_level + // annotation found in the parent chain" (unknown). + Risk() (level Risk, ok bool) + + // Identities returns the supported identities. nil signals "no + // supportedIdentities annotation in the parent chain". + Identities() []Identity + + // Annotation exposes the raw cobra annotation map for plugins that + // need a tag the framework does not surface. + Annotation(key string) (string, bool) +} diff --git a/go.mod b/go.mod index 770cdf589..7862e24ea 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.0 require ( github.com/Microsoft/go-winio v0.6.2 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/gofrs/flock v0.8.1 @@ -21,6 +22,7 @@ require ( golang.org/x/sys v0.33.0 golang.org/x/term v0.27.0 golang.org/x/text v0.23.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -60,5 +62,4 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 451a3591d..e6026a683 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= diff --git a/internal/cmdmeta/meta.go b/internal/cmdmeta/meta.go new file mode 100644 index 000000000..f0a9ea6b4 --- /dev/null +++ b/internal/cmdmeta/meta.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package cmdmeta is the single source of truth for command metadata that the +// policy engine and the hook selector both consume. It wraps the existing +// cmdutil annotations (risk_level, supportedIdentities) and adds the +// "domain" axis that the hook selector and Rule path globs need. +// +// Three axes: +// +// - Domain - business domain ("im", "docs", "contact", ...). Inherited +// from the nearest ancestor when not set on the command +// itself. Stored on a new annotation key (the cmdutil +// risk_level / supportedIdentities keys are left untouched +// for backward compatibility). +// - Risk - "read" | "write" | "high-risk-write". Inherited like +// Domain. Reuses cmdutil.SetRisk / GetRisk under the hood. +// - Identities - allowed identity set. Child explicit override semantics: +// the first ancestor (including self) with a non-nil set +// wins. Reuses cmdutil.SetSupportedIdentities / +// GetSupportedIdentities. +// +// Missing values are returned as the zero value with ok=false (where the +// signature exposes it). Interpretation is up to the consumer: the policy +// engine treats a missing risk as fail-closed when a Rule is registered +// without AllowUnannotated=true, and as allow otherwise. Identities still +// defaults to ALLOW. Do not synthesise defaults here -- let each consumer +// decide. +package cmdmeta + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" +) + +// domainAnnotationKey is the cobra Annotation key for the business domain. +// Kept distinct from cmdutil.* keys so this package can evolve without +// disturbing existing readers. +const domainAnnotationKey = "cmdmeta.domain" + +// Meta groups the three command-level metadata axes consumed by the policy +// engine and hook selectors. +type Meta struct { + Domain string + Risk string + Identities []string +} + +// Apply writes metadata onto a cobra command. Empty fields are skipped: pass +// the value via the underlying cmdutil setter if you need to write an empty +// string / empty slice explicitly. +func Apply(cmd *cobra.Command, m Meta) { + if m.Domain != "" { + SetDomain(cmd, m.Domain) + } + if m.Risk != "" { + cmdutil.SetRisk(cmd, m.Risk) + } + if m.Identities != nil { + cmdutil.SetSupportedIdentities(cmd, m.Identities) + } +} + +// Get resolves the effective metadata for a command, walking up the parent +// chain for Domain, Risk, and Identities. All three axes use the same +// nearest-ancestor-wins rule. +// +// Identities note: cmdutil.GetSupportedIdentities collapses both the +// "annotation absent" and "annotation set to empty string" cases to nil. +// A child cannot therefore express "deny inheritance" with an empty +// annotation; the walk simply continues up the parent chain when nil is +// returned. To override a parent, the child must set a non-empty slice +// (e.g. ["bot"]). +func Get(cmd *cobra.Command) Meta { + risk, _ := Risk(cmd) + return Meta{ + Domain: Domain(cmd), + Risk: risk, + Identities: Identities(cmd), + } +} + +// SetDomain stores the domain annotation on a single command (no +// inheritance is performed on write). +func SetDomain(cmd *cobra.Command, domain string) { + if domain == "" { + return + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[domainAnnotationKey] = domain +} + +// Domain returns the nearest-ancestor domain for the command. Empty string +// when no ancestor has the annotation -- this is the "unknown" state the +// policy engine must treat as ALLOW. +func Domain(cmd *cobra.Command) string { + for c := cmd; c != nil; c = c.Parent() { + if c.Annotations == nil { + continue + } + if v, ok := c.Annotations[domainAnnotationKey]; ok && v != "" { + return v + } + } + return "" +} + +// Risk returns the nearest-ancestor risk level (via cmdutil.GetRisk). +// ok=false signals "unknown" -- the policy engine treats this as +// fail-closed (deny with risk_not_annotated) whenever a Rule without +// AllowUnannotated=true is active, and as allow otherwise. +func Risk(cmd *cobra.Command) (level string, ok bool) { + for c := cmd; c != nil; c = c.Parent() { + if level, ok = cmdutil.GetRisk(c); ok { + return level, true + } + } + return "", false +} + +// Identities returns the first non-nil identity set found while walking up +// the parent chain. nil signals "unknown" -- the policy engine treats this +// as ALLOW. +// +// cmdutil.GetSupportedIdentities returns nil when the annotation is absent +// or empty; an explicit non-empty set (even ["user"] alone) stops the walk. +func Identities(cmd *cobra.Command) []string { + for c := cmd; c != nil; c = c.Parent() { + if ids := cmdutil.GetSupportedIdentities(c); ids != nil { + return ids + } + } + return nil +} diff --git a/internal/cmdmeta/meta_test.go b/internal/cmdmeta/meta_test.go new file mode 100644 index 000000000..61e831319 --- /dev/null +++ b/internal/cmdmeta/meta_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdmeta_test + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdmeta" + "github.com/larksuite/cli/internal/cmdutil" +) + +func TestApply_writesAllFields(t *testing.T) { + cmd := &cobra.Command{Use: "fetch"} + cmdmeta.Apply(cmd, cmdmeta.Meta{ + Domain: "docs", + Risk: "write", + Identities: []string{"user", "bot"}, + }) + + if got := cmdmeta.Domain(cmd); got != "docs" { + t.Fatalf("Domain = %q, want %q", got, "docs") + } + if got, ok := cmdmeta.Risk(cmd); !ok || got != "write" { + t.Fatalf("Risk = (%q,%v), want (%q,true)", got, ok, "write") + } + if got := cmdmeta.Identities(cmd); !reflect.DeepEqual(got, []string{"user", "bot"}) { + t.Fatalf("Identities = %v, want [user bot]", got) + } +} + +func TestApply_emptyFieldsSkipped(t *testing.T) { + cmd := &cobra.Command{Use: "fetch"} + cmdmeta.Apply(cmd, cmdmeta.Meta{}) // nothing + if got := cmdmeta.Domain(cmd); got != "" { + t.Fatalf("Domain expected unset, got %q", got) + } + if _, ok := cmdmeta.Risk(cmd); ok { + t.Fatalf("Risk expected unset") + } + if got := cmdmeta.Identities(cmd); got != nil { + t.Fatalf("Identities expected nil, got %v", got) + } +} + +// Domain inherits from the nearest ancestor; risk and identities behave the +// same way. We verify each axis with a 3-level tree: +// +// root (domain=docs, risk=read, identities=[user]) +// group +// leaf +func TestGet_inheritsFromAncestor(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + group := &cobra.Command{Use: "docs"} + leaf := &cobra.Command{Use: "fetch"} + root.AddCommand(group) + group.AddCommand(leaf) + + cmdmeta.Apply(root, cmdmeta.Meta{ + Domain: "docs", + Risk: "read", + Identities: []string{"user"}, + }) + + got := cmdmeta.Get(leaf) + want := cmdmeta.Meta{ + Domain: "docs", + Risk: "read", + Identities: []string{"user"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Get(leaf) = %+v, want %+v", got, want) + } +} + +// Closest ancestor wins -- a mid-level override is preferred over root. +func TestGet_nearestAncestorWins(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + group := &cobra.Command{Use: "docs"} + leaf := &cobra.Command{Use: "fetch"} + root.AddCommand(group) + group.AddCommand(leaf) + + cmdmeta.SetDomain(root, "docs") + cmdmeta.SetDomain(group, "docs-override") + cmdutil.SetRisk(root, "read") + cmdutil.SetRisk(group, "high-risk-write") + + if got := cmdmeta.Domain(leaf); got != "docs-override" { + t.Fatalf("Domain = %q, want docs-override (nearest)", got) + } + if got, _ := cmdmeta.Risk(leaf); got != "high-risk-write" { + t.Fatalf("Risk = %q, want high-risk-write (nearest)", got) + } +} + +// Unknown axes return zero / nil so the policy engine can apply the +// "unknown => ALLOW" contract. +func TestGet_unknownReturnsZero(t *testing.T) { + cmd := &cobra.Command{Use: "orphan"} + if got := cmdmeta.Domain(cmd); got != "" { + t.Fatalf("Domain = %q, want empty for unknown", got) + } + if level, ok := cmdmeta.Risk(cmd); ok || level != "" { + t.Fatalf("Risk = (%q,%v), want empty / false for unknown", level, ok) + } + if ids := cmdmeta.Identities(cmd); ids != nil { + t.Fatalf("Identities = %v, want nil for unknown", ids) + } +} + +// Child explicitly overriding identities stops the parent walk. +func TestIdentities_childOverridesParent(t *testing.T) { + parent := &cobra.Command{Use: "docs"} + child := &cobra.Command{Use: "preview"} + parent.AddCommand(child) + + cmdutil.SetSupportedIdentities(parent, []string{"user", "bot"}) + cmdutil.SetSupportedIdentities(child, []string{"bot"}) + + got := cmdmeta.Identities(child) + if !reflect.DeepEqual(got, []string{"bot"}) { + t.Fatalf("Identities(child) = %v, want [bot]", got) + } +} + +// SetDomain with empty value is a no-op (no annotation written, so a +// later inherited read still works). +func TestSetDomain_emptyIsNoop(t *testing.T) { + parent := &cobra.Command{Use: "docs"} + cmdmeta.SetDomain(parent, "docs") + + child := &cobra.Command{Use: "fetch"} + parent.AddCommand(child) + + cmdmeta.SetDomain(child, "") // no-op + if got := cmdmeta.Domain(child); got != "docs" { + t.Fatalf("Domain(child) = %q, want inherited 'docs'", got) + } +} diff --git a/internal/cmdpolicy/active.go b/internal/cmdpolicy/active.go new file mode 100644 index 000000000..488d641c1 --- /dev/null +++ b/internal/cmdpolicy/active.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "sync" + + "github.com/larksuite/cli/extension/platform" +) + +// ActivePolicy is the resolved user-layer policy after applyUserPolicyPruning +// has run during bootstrap. `lark-cli config policy show` reads this to +// answer "what rule is currently in effect, and how many commands does +// it hide?". +// +// Set once at bootstrap time; consumed read-only thereafter. +type ActivePolicy struct { + Rule *platform.Rule + Source ResolveSource + DeniedPaths int // number of commands the engine marked as denied (post-aggregation) +} + +var ( + activeMu sync.RWMutex + activePolicy *ActivePolicy +) + +// SetActive records the policy that ends up applied. Called exactly once +// per process from cmd/policy.go::applyUserPolicyPruning. The mutex is +// belt-and-braces in case future test paths interleave with bootstrap. +// +// A deep copy is taken so the snapshot is immune to later mutations of +// the input by the caller (a plugin-supplied *Rule could otherwise +// mutate the embedded Allow/Deny/Identities slices after we stored it). +func SetActive(p *ActivePolicy) { + activeMu.Lock() + defer activeMu.Unlock() + if p == nil { + activePolicy = nil + return + } + activePolicy = cloneActivePolicy(p) +} + +// GetActive returns a deep copy of the recorded policy, or nil if +// bootstrap has not finished or no rule applied. Callers can freely +// mutate the result — including the embedded Rule slices — without +// affecting the stored global. +func GetActive() *ActivePolicy { + activeMu.RLock() + defer activeMu.RUnlock() + if activePolicy == nil { + return nil + } + return cloneActivePolicy(activePolicy) +} + +// cloneActivePolicy deep-copies the top-level struct plus the embedded +// Rule's slice fields. Other fields (Source, DeniedPaths) are value +// types so the struct copy already disjoints them. +func cloneActivePolicy(in *ActivePolicy) *ActivePolicy { + if in == nil { + return nil + } + cp := *in + if in.Rule != nil { + rule := *in.Rule + rule.Allow = append([]string(nil), in.Rule.Allow...) + rule.Deny = append([]string(nil), in.Rule.Deny...) + rule.Identities = append([]platform.Identity(nil), in.Rule.Identities...) + cp.Rule = &rule + } + return &cp +} + +// ResetActiveForTesting clears the recorded policy. Tests must call this +// in t.Cleanup when they exercise the bootstrap path. +func ResetActiveForTesting() { + activeMu.Lock() + defer activeMu.Unlock() + activePolicy = nil +} diff --git a/internal/cmdpolicy/aggregation_test.go b/internal/cmdpolicy/aggregation_test.go new file mode 100644 index 000000000..59384952a --- /dev/null +++ b/internal/cmdpolicy/aggregation_test.go @@ -0,0 +1,364 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// EvaluateAll must skip non-runnable parent groups (their decision is +// derived in the aggregation pass). The previous regression: an +// Allow:["docs/**"] rule incorrectly denied the parent "docs" group too, +// because the parent's own path "docs" did not match "docs/**". +func TestEvaluateAll_skipsPureGroups(t *testing.T) { + root := buildTree() // docs and im are pure groups, +fetch / +update / +send are leaves + e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) + got := e.EvaluateAll(root) + + if _, present := got["docs"]; present { + t.Errorf("parent group 'docs' should not appear in Decisions (Allow=docs/**)") + } + if _, present := got["im"]; present { + t.Errorf("parent group 'im' should not appear in Decisions") + } + + // Children still evaluated normally. + if !got["docs/+fetch"].Allowed { + t.Errorf("docs/+fetch should still be allowed by docs/**") + } +} + +// BuildDeniedByPath must aggregate: a parent group whose every runnable +// child is denied must itself get an aggregated Denial in the map. +func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) { + // Custom tree where ALL children of "im" will be denied. + root := &cobra.Command{Use: "lark-cli"} + im := &cobra.Command{Use: "im"} + root.AddCommand(im) + send := &cobra.Command{Use: "+send", RunE: noop} + cmdutil.SetRisk(send, "write") + im.AddCommand(send) + search := &cobra.Command{Use: "+search", RunE: noop} + cmdutil.SetRisk(search, "read") + im.AddCommand(search) + + // Risk is set on both leaves so the rejection comes from the Allow + // axis (the contract this test pins), not from the risk gate. + e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) // none of im/* matches + decisions := e.EvaluateAll(root) + + // Pin the rejection axis: both leaves are rejected by Allow miss, + // NOT by the risk_not_annotated gate. If a future edit drops the + // SetRisk lines above, this assertion fails and the test stops + // silently testing the wrong axis. + if rc := decisions["im/+send"].ReasonCode; rc != "domain_not_allowed" { + t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed", rc) + } + if rc := decisions["im/+search"].ReasonCode; rc != "domain_not_allowed" { + t.Errorf("im/+search ReasonCode = %q, want domain_not_allowed", rc) + } + + denied := cmdpolicy.BuildDeniedByPath(root, decisions, + cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent") + + // Both leaves denied. + if _, ok := denied["im/+send"]; !ok { + t.Errorf("im/+send should be in denied map") + } + if _, ok := denied["im/+search"]; !ok { + t.Errorf("im/+search should be in denied map") + } + // Parent must be aggregated. + parent, ok := denied["im"] + if !ok { + t.Fatalf("parent 'im' should be aggregated into denied map") + } + if parent.Layer != "policy" { + t.Errorf("parent.Layer = %q, want pruning", parent.Layer) + } +} + +// Partial children-denied means parent stays UN-denied. This is the +// counter-case to the previous regression: docs/** allowed children stays +// alive even if some siblings are denied. +func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + + fetch := &cobra.Command{Use: "+fetch", RunE: noop} + cmdutil.SetRisk(fetch, "read") + docs.AddCommand(fetch) // allowed + + delete := &cobra.Command{Use: "+delete", RunE: noop} + cmdutil.SetRisk(delete, "high-risk-write") + docs.AddCommand(delete) // denied by Deny + + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + Deny: []string{"docs/+delete"}, + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy") + + if _, ok := denied["docs"]; ok { + t.Errorf("parent 'docs' must NOT be denied when some children are allowed") + } + if _, ok := denied["docs/+fetch"]; ok { + t.Errorf("docs/+fetch should not be in denied map (it's allowed)") + } + if _, ok := denied["docs/+delete"]; !ok { + t.Errorf("docs/+delete should be denied (in Deny)") + } +} + +// The binary root is never installed with a denyStub even when all its +// descendants are denied -- the entry point must remain dispatchable. +func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) { + root := buildTree() + e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}}) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "") + + // Every leaf should be denied. We do not assert on the root entry + // because Apply skips the root regardless; the contract is "root + // stays dispatchable". + if _, ok := denied["lark-cli"]; ok { + t.Errorf("root should not be in denied map") + } +} + +// Hybrid command: a parent with its own RunE plus children. Aggregation +// requires both own RunE denied AND all children denied for the parent +// itself to be marked denied. +func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs", RunE: noop} // hybrid: own RunE + subs + cmdutil.SetRisk(docs, "read") + root.AddCommand(docs) + delete := &cobra.Command{Use: "+delete", RunE: noop} + cmdutil.SetRisk(delete, "high-risk-write") + docs.AddCommand(delete) + + // Allow "docs" (parent) but deny "+delete" child. + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs"}, + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "") + + // docs/+delete denied (path doesn't match Allow=["docs"]). + if _, ok := denied["docs/+delete"]; !ok { + t.Errorf("docs/+delete should be denied") + } + // docs itself allowed (path matches Allow=["docs"] exactly). + if _, ok := denied["docs"]; ok { + t.Errorf("docs (hybrid) should NOT be denied -- own RunE is allowed") + } +} + +// Apply with the wrapped *output.ExitError exposes BOTH paths consumers +// rely on: +// 1. cmd/root.go's envelope writer (errors.As on *output.ExitError) +// 2. in-process consumers extracting the platform.CommandDeniedError +func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) { + root := buildTree() + denied := map[string]cmdpolicy.Denial{ + "docs/+update": { + Layer: "policy", + PolicySource: "plugin:secaudit", + RuleName: "secaudit-policy", + ReasonCode: "write_not_allowed", + Reason: "write disabled", + }, + } + cmdpolicy.Apply(root, denied) + update := findChild(t, root, "docs", "+update") + + err := update.RunE(update, []string{}) + if err == nil { + t.Fatalf("denied command should return error") + } + + // Path 1: envelope-writer view. + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("error chain must contain *output.ExitError, got %T", err) + } + if exitErr.Detail == nil { + t.Fatalf("ExitError.Detail required for envelope to render") + } + if exitErr.Detail.Type != "command_denied" { + t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type) + } + // JSON envelope shape: detail.reason_code must be present and + // match the closed enum. + detailMap, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail) + } + if detailMap["reason_code"] != "write_not_allowed" { + t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"]) + } + if detailMap["policy_source"] != "plugin:secaudit" { + t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"]) + } + + // Path 2: in-process typed-error view. + var cd *platform.CommandDeniedError + if !errors.As(err, &cd) { + t.Fatalf("error chain must expose *platform.CommandDeniedError") + } + if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" { + t.Errorf("CommandDeniedError = %+v", cd) + } + + // Envelope round-trip sanity (the actual JSON cmd/root.go would emit). + var buf strings.Builder + output.WriteErrorEnvelope(&buf, exitErr, "user") + if !strings.Contains(buf.String(), `"type": "command_denied"`) { + t.Errorf("envelope JSON missing type=command_denied, got:\n%s", buf.String()) + } + if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) { + t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String()) + } + // Round-trip parse to verify it's well-formed JSON. + var parsed map[string]any + if err := json.Unmarshal([]byte(buf.String()), &parsed); err != nil { + t.Fatalf("envelope JSON malformed: %v\n%s", err, buf.String()) + } +} + +// Regression: a pure parent group carrying AnnotationPureGroup must be +// skipped by both EvaluateAll and aggregateParents. Without the skip, +// the cmd.installUnknownSubcommandGuard pass (which attaches a RunE to +// every group for cobra's silent-help fallback) would flip Runnable() +// to true for `docs`, `drive`, etc., and a yaml rule like +// `max_risk: read` would deny every ` --help` invocation with +// reason_code = risk_not_annotated. +func TestEvaluateAll_skipsAnnotatedPureGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + drive := &cobra.Command{ + Use: "drive", + RunE: func(*cobra.Command, []string) error { return nil }, // emulate guard injection + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + root.AddCommand(drive) + pull := &cobra.Command{Use: "+pull", RunE: noop} + cmdutil.SetRisk(pull, "read") + drive.AddCommand(pull) + + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + got := e.EvaluateAll(root) + + if d, present := got["drive"]; present { + t.Errorf("annotated pure group should not appear in Decisions; got %+v", d) + } + if !got["drive/+pull"].Allowed { + t.Errorf("leaf under pure group must still be evaluated; got %+v", got["drive/+pull"]) + } +} + +// Regression: hasRunnableDescendant must also treat +// AnnotationPureGroup-tagged commands as non-runnable. Without the +// skip, an entire branch consisting of a pure-group placeholder + a +// single pure-group leaf would advertise itself as a "live" subtree +// and the parent aggregation pass would refuse to install a deny stub +// (allLiveChildrenDenied flips to false because the pure group is +// neither runnable nor in `denied`). +func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + + // A pure-group sibling of a real leaf. The parent must still + // aggregate based on the real leaf alone. + placeholder := &cobra.Command{ + Use: "placeholder", + RunE: func(*cobra.Command, []string) error { return nil }, + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + docs.AddCommand(placeholder) + noChild := &cobra.Command{ + Use: "+ghost", + RunE: func(*cobra.Command, []string) error { return nil }, + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + placeholder.AddCommand(noChild) + + fetch := &cobra.Command{Use: "+fetch", RunE: noop} + cmdutil.SetRisk(fetch, "write") + docs.AddCommand(fetch) + + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + decisions := e.EvaluateAll(root) + denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "") + + if _, ok := denied["docs"]; !ok { + t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied) + } +} + +// Regression: aggregateParents must treat an AnnotationPureGroup-tagged +// command exactly like a parent-only group. With cmdRunnable accidentally +// true (RunE attached by the guard), the aggregator would otherwise look +// for an own-RunE denial entry and skip aggregation, leaving ` +// --help` reachable even when every live child is denied. +func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + drive := &cobra.Command{ + Use: "drive", + RunE: func(*cobra.Command, []string) error { return nil }, + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + root.AddCommand(drive) + push := &cobra.Command{Use: "+push", RunE: noop} + cmdutil.SetRisk(push, "write") + drive.AddCommand(push) + pull := &cobra.Command{Use: "+pull", RunE: noop} + cmdutil.SetRisk(pull, "write") + drive.AddCommand(pull) + + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + decisions := e.EvaluateAll(root) + denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "") + + if _, ok := denied["drive"]; !ok { + t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied) + } +} + +// The binary root must never receive a denyStub even if every descendant +// is denied. cobra still needs root to dispatch help / completion. +func TestApply_neverInstallsOnRoot(t *testing.T) { + root := buildTree() + denied := map[string]cmdpolicy.Denial{ + "lark-cli": {Layer: "policy", ReasonCode: "all_children_denied"}, + } + cmdpolicy.Apply(root, denied) + if root.RunE != nil { + t.Errorf("root.RunE should remain nil; got a denyStub installed") + } + if root.Hidden { + t.Errorf("root must stay visible") + } +} diff --git a/internal/cmdpolicy/apply.go b/internal/cmdpolicy/apply.go new file mode 100644 index 000000000..fead7fd4d --- /dev/null +++ b/internal/cmdpolicy/apply.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/output" +) + +// Apply walks the command tree and installs denyStubs for every path in +// deniedByPath whose Denial.Layer == "policy". It is the user-layer +// counterpart to applyStrictModeDenials in cmd/prune.go; both consume the +// same deniedByPath map produced by the bootstrap pipeline, neither +// re-evaluates rules. +// +// Three things must happen for every denied command (hard-constraints 1-4 +// in the tech doc): +// +// 1. cmd.Hidden = true -- removes from help / completion +// 2. cmd.DisableFlagParsing = true -- denial-wins invariant; otherwise +// cobra would intercept the call +// with "missing required flag" +// before we can return our error +// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so +// cmd/root.go's envelope writer +// emits structured JSON (with +// error.type = denial.Layer and +// detail.reason_code = ReasonCode); +// the wrapped error chain still +// exposes *platform.CommandDeniedError +// via errors.As for in-process +// consumers +// +// Apply must be called once during the Bootstrap pipeline BEFORE +// cobra.Execute. It mutates the command tree in place and is not safe to +// call concurrently with command dispatch. Returns the number of commands +// modified. +func Apply(root *cobra.Command, deniedByPath map[string]Denial) int { + if root == nil || len(deniedByPath) == 0 { + return 0 + } + + count := 0 + walkTree(root, func(c *cobra.Command) { + // Never install a denyStub on the binary root itself. Even if the + // aggregation pass somehow marked it (e.g. all-children-denied at + // the top), the binary entry point must remain dispatchable so + // cobra's own help / completion paths still work. + if !c.HasParent() { + return + } + path := CanonicalPath(c) + if path == "" { + return + } + d, ok := deniedByPath[path] + if !ok || d.Layer != LayerPolicy { + return + } + if installDenyStub(c, path, d) { + count++ + } + }) + return count +} + +// AnnotationDenialLayer / AnnotationDenialSource carry the denial +// signal to internal/hook through cobra annotations, avoiding an +// import cycle between hook and cmdpolicy. +const ( + AnnotationDenialLayer = "lark:policy_denied_layer" + AnnotationDenialSource = "lark:policy_denied_source" + + // AnnotationPureGroup marks a cobra.Command that is logically a + // parent-only group but had a RunE attached by the bootstrap-time + // unknown-subcommand guard. The engine treats annotated commands + // the same as un-annotated parent groups (no RunE): they are not + // evaluated against the Rule, and aggregateParents does not treat + // them as hybrids. + // + // Without this signal, a user enabling a policy.yml with + // max_risk: read would see every group (`lark-cli drive --help`, + // `lark-cli docs --help`) return exit 2 + risk_not_annotated, + // because the guard's RunE flips Runnable()=true and the engine + // then demands a risk_level annotation on the group itself. + AnnotationPureGroup = "lark:cmd_pure_group" +) + +// IsPureGroup reports whether cmd carries the AnnotationPureGroup marker. +// Used by the engine to skip evaluation and by the aggregator to treat the +// command as a parent-only group regardless of cobra's Runnable() answer. +func IsPureGroup(cmd *cobra.Command) bool { + if cmd == nil || cmd.Annotations == nil { + return false + } + return cmd.Annotations[AnnotationPureGroup] == "true" +} + +// CommandDeniedFromDenial materialises the wrapped error type carried +// on ExitError.Err so errors.As works for in-process consumers. +func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError { + return &platform.CommandDeniedError{ + Path: path, + Layer: d.Layer, + PolicySource: d.PolicySource, + RuleName: d.RuleName, + ReasonCode: d.ReasonCode, + Reason: d.Reason, + } +} + +// DenialDetailMap is the canonical detail.* shape every `command_denied` +// envelope shares (see docs/extension/reason-codes.md). Use it as +// ErrDetail.Detail when constructing an envelope outside BuildDenialError. +func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any { + return map[string]any{ + "path": cd.Path, + "layer": cd.Layer, + "policy_source": cd.PolicySource, + "rule_name": cd.RuleName, + "reason_code": cd.ReasonCode, + "reason": cd.Reason, + } +} + +// BuildDenialError is the default envelope for user-layer denials: +// Message comes from CommandDeniedError.Error(), no Hint. Callers that +// need a custom Message or an independent Hint (strict-mode) should +// compose CommandDeniedFromDenial + DenialDetailMap themselves. +func BuildDenialError(path string, d Denial) *output.ExitError { + cd := CommandDeniedFromDenial(path, d) + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "command_denied", + Message: cd.Error(), + Detail: DenialDetailMap(cd), + }, + Err: cd, + } +} + +// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go +// which does RemoveCommand+AddCommand (changing the pointer), we modify +// the existing node so any external reference (snapshots, alias targets) +// continues to point at the same cmd. +// +// Help fields (cmd.Short / cmd.Long / cmd.Flags()) are deliberately +// preserved so `--help` on a denied command still describes what the +// command was intended to do. +// +// Two cobra Annotations are set as a denial signal that internal/hook +// reads (without taking a dependency on this package): +// +// - AnnotationDenialLayer -> "policy" or "strict_mode" +// - AnnotationDenialSource -> the PolicySource ("yaml", "plugin:foo", ...) +// +// Returns true when the stub was actually installed and false on the +// strict-mode early-return so callers can compute an accurate "commands +// modified" count. +func installDenyStub(cmd *cobra.Command, path string, d Denial) bool { + // strict-mode wins over user-layer pruning. If the command was + // already replaced by a strict-mode stub (cmd/prune.go::strictModeStubFrom + // writes layer=strict_mode), do NOT overwrite -- the user-layer + // rule cannot relax or relabel a credential-hard boundary. + // + // Behaviour without this guard (pre-fix): a user yaml rule matching + // a strict-mode stub's path would replace the RunE with the pruning + // denyStub, hiding the original strict-mode error message AND + // re-labelling detail.layer from "strict_mode" to "policy". + if cmd.Annotations != nil && + cmd.Annotations[AnnotationDenialLayer] == LayerStrictMode { + return false + } + cmd.Hidden = true + cmd.DisableFlagParsing = true + + // Bypass cobra's pre-RunE gates that would otherwise short-circuit + // before the wrapped RunE (= where observers + denial guard live): + // + // 1. Args validator: original commands often declare cobra.NoArgs + // or a custom Args function. With DisableFlagParsing=true, + // `--doc xxx` looks like positional args; cobra.ValidateArgs + // fires BEFORE PersistentPreRunE / PreRunE / RunE and would + // surface a Cobra usage error instead of our pruning envelope. + // ArbitraryArgs accepts everything. + // + // 2. Parent's PersistentPreRunE: cobra's "first PersistentPreRunE + // wins" walks UP from the leaf. cmd/auth/auth.go declares a + // PersistentPreRunE that returns external_provider when env + // credentials are set; without our leaf-level override, that + // fires before pruning's RunE and the caller sees the wrong + // envelope. We set a no-op leaf PersistentPreRunE that just + // silences usage and returns nil, so dispatch proceeds to the + // wrapped RunE (which produces the real pruning envelope and + // lets Before/After observers fire). + cmd.Args = cobra.ArbitraryArgs + cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + } + cmd.PersistentPreRun = nil + cmd.PreRunE = nil + cmd.PreRun = nil + + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[AnnotationDenialLayer] = d.Layer + cmd.Annotations[AnnotationDenialSource] = d.PolicySource + + denial := d // capture by value for the closure + cmd.RunE = func(c *cobra.Command, args []string) error { + // error.type is the user-facing semantic ("a command was denied by + // policy"). detail.layer carries the implementation distinction + // ("policy" vs "strict_mode") for debugging. + return BuildDenialError(path, denial) + } + // Clear any pre-existing Run hook: cobra prefers RunE when both are + // set, but leaving a stale Run around is a foot-gun for future + // maintainers. + cmd.Run = nil + return true +} diff --git a/internal/cmdpolicy/denial.go b/internal/cmdpolicy/denial.go new file mode 100644 index 000000000..3411984d0 --- /dev/null +++ b/internal/cmdpolicy/denial.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import "sort" + +// Layer values match CommandDeniedError.Layer and the detail.layer +// field of the JSON envelope (under error.type = "command_denied"). +const ( + LayerStrictMode = "strict_mode" + // LayerPolicy is the user-layer enforcement label. The string value + // is "policy" — the package name "cmdpolicy" matches it. This + // replaces the older "pruning" label. + LayerPolicy = "policy" +) + +// Denial is the merged record for a single rejected command path. It +// is distinct from the user-layer-only Decision type: Denial only +// exists when the command is rejected (the Allowed bool would be +// wasted here, hence not reusing Decision). +type Denial struct { + Layer string // "strict_mode" | "policy" + PolicySource string // "plugin:secaudit" | "yaml:mywork" | "strict-mode" | "" + RuleName string // matched Rule.Name (if any) + ReasonCode string // closed enum, see docs/extension/reason-codes.md + Reason string // human-readable +} + +// ChildDenial is what AggregateChildren consumes — it pairs a Denial +// with the child command's path so the aggregate can carry that +// breakdown for envelope.detail.children_denied. +type ChildDenial struct { + Path string + Denial Denial +} + +// AggregateChildren produces the parent-group Denial when every child +// of a command group is itself denied. The rules: +// +// - all children share Layer "strict_mode" → parent Layer = +// strict_mode, parent ReasonCode = single child's ReasonCode (if +// consistent) or "mixed_children_strict_mode" otherwise. +// - all children share Layer "policy" → parent Layer = policy, +// ReasonCode behaves analogously. +// - mixed layers across children → parent Layer = "policy", +// ReasonCode = "all_children_denied", PolicySource = "mixed". +// +// Calling with an empty slice returns a zero Denial — callers should +// treat this as "no aggregation needed". +func AggregateChildren(children []ChildDenial) Denial { + if len(children) == 0 { + return Denial{} + } + + layers := map[string]struct{}{} + reasonCodes := map[string]struct{}{} + sources := map[string]struct{}{} + ruleNames := map[string]struct{}{} + for _, c := range children { + layers[c.Denial.Layer] = struct{}{} + reasonCodes[c.Denial.ReasonCode] = struct{}{} + if c.Denial.PolicySource != "" { + sources[c.Denial.PolicySource] = struct{}{} + } + if c.Denial.RuleName != "" { + ruleNames[c.Denial.RuleName] = struct{}{} + } + } + + // Mixed: layers differ across children. Parent goes to Layer=policy + // (the more "user-recoverable" of the two — swapping policy can + // flip children, swapping credential cannot). + if len(layers) > 1 { + return Denial{ + Layer: LayerPolicy, + PolicySource: "mixed", + ReasonCode: "all_children_denied", + Reason: "all child commands are denied (mixed reasons)", + } + } + + var layer string + for l := range layers { + layer = l + } + + d := Denial{Layer: layer} + + switch len(reasonCodes) { + case 1: + for rc := range reasonCodes { + d.ReasonCode = rc + } + default: + switch layer { + case LayerStrictMode: + d.ReasonCode = "mixed_children_strict_mode" + default: + d.ReasonCode = "mixed_children_policy" + } + } + + if len(sources) == 1 { + for s := range sources { + d.PolicySource = s + } + } + if layer == LayerStrictMode { + d.PolicySource = "strict-mode" + } + + if len(ruleNames) == 1 { + for n := range ruleNames { + d.RuleName = n + } + } + + d.Reason = "all child commands are denied" + return d +} + +// SortChildren orders children by Path. The aggregate output of +// AggregateChildren is deterministic regardless of slice order, but +// tests and the envelope's children_denied list want a stable order. +func SortChildren(children []ChildDenial) { + sort.Slice(children, func(i, j int) bool { + return children[i].Path < children[j].Path + }) +} diff --git a/internal/cmdpolicy/denial_test.go b/internal/cmdpolicy/denial_test.go new file mode 100644 index 000000000..6c66665cb --- /dev/null +++ b/internal/cmdpolicy/denial_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "testing" + + "github.com/larksuite/cli/internal/cmdpolicy" +) + +func TestAggregateChildren_allSameLayerAndReason(t *testing.T) { + got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Path: "docs/+update", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent", + ReasonCode: "write_not_allowed", RuleName: "agent-policy", + }}, + {Path: "docs/+delete", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent", + ReasonCode: "write_not_allowed", RuleName: "agent-policy", + }}, + }) + if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "write_not_allowed" { + t.Fatalf("got %+v, want layer=policy reason=write_not_allowed", got) + } + if got.PolicySource != "yaml:agent" || got.RuleName != "agent-policy" { + t.Fatalf("Source / RuleName should propagate when consistent, got %+v", got) + } +} + +func TestAggregateChildren_sameLayerMixedReasons(t *testing.T) { + got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "write_not_allowed"}}, + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed"}}, + }) + if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "mixed_children_policy" { + t.Fatalf("got %+v, want layer=policy reason=mixed_children_policy", got) + } +} + +func TestAggregateChildren_strictModeBranch(t *testing.T) { + got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}}, + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}}, + }) + if got.Layer != cmdpolicy.LayerStrictMode || got.ReasonCode != "identity_not_supported" { + t.Fatalf("got %+v", got) + } + if got.PolicySource != "strict-mode" { + t.Fatalf("PolicySource = %q, want strict-mode", got.PolicySource) + } +} + +// Mixed layers (some strict_mode, some policy) collapse to Layer=policy +// per the design rule — a parent group failing for "both" reasons is +// most actionable framed as a user-policy issue (swappable) rather than +// a credential capability one (not swappable). +func TestAggregateChildren_mixedLayersFallsToPolicy(t *testing.T) { + got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Path: "docs/+update", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported", + }}, + {Path: "docs/+fetch", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed", + }}, + }) + if got.Layer != cmdpolicy.LayerPolicy { + t.Fatalf("Layer = %q, want policy (mixed-children rule)", got.Layer) + } + if got.ReasonCode != "all_children_denied" { + t.Fatalf("ReasonCode = %q, want all_children_denied", got.ReasonCode) + } + if got.PolicySource != "mixed" { + t.Fatalf("PolicySource = %q, want mixed", got.PolicySource) + } +} + +func TestAggregateChildren_emptySlice(t *testing.T) { + got := cmdpolicy.AggregateChildren(nil) + if (got != cmdpolicy.Denial{}) { + t.Fatalf("empty slice should produce zero Denial, got %+v", got) + } +} + +func TestSortChildren_stableOrder(t *testing.T) { + children := []cmdpolicy.ChildDenial{ + {Path: "docs/+update"}, + {Path: "docs/+delete"}, + {Path: "docs/+create"}, + } + cmdpolicy.SortChildren(children) + want := []string{"docs/+create", "docs/+delete", "docs/+update"} + for i, c := range children { + if c.Path != want[i] { + t.Fatalf("children[%d].Path = %q, want %q", i, c.Path, want[i]) + } + } +} diff --git a/internal/cmdpolicy/diagnostic.go b/internal/cmdpolicy/diagnostic.go new file mode 100644 index 000000000..9b2393248 --- /dev/null +++ b/internal/cmdpolicy/diagnostic.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +// diagnosticPaths lists command paths that are unconditionally allowed, +// regardless of any user-layer Rule. Entries must satisfy two properties: +// +// 1. Read-only. The command performs no I/O outside the local process +// and never mutates remote state. +// 2. Self-reflective. Denying the command would produce a UX dead-end +// where the operator can no longer inspect / validate the policy +// that is locking them out. +// +// Today this is `config policy show` and `config plugins show` -- +// both purely local introspection over the resolved policy. Keep the +// list small and audited: every entry is a permanent hole in the +// fail-closed boundary. +var diagnosticPaths = map[string]bool{ + "config/policy/show": true, + "config/plugins/show": true, +} + +// IsDiagnosticPath reports whether the given canonical command path is +// exempt from user-layer pruning. Exported for test packages; callers +// inside this package use the unexported helper. +func IsDiagnosticPath(path string) bool { + return diagnosticPaths[path] +} diff --git a/internal/cmdpolicy/diagnostic_test.go b/internal/cmdpolicy/diagnostic_test.go new file mode 100644 index 000000000..cc1c3ffa6 --- /dev/null +++ b/internal/cmdpolicy/diagnostic_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" +) + +// configPolicyTree builds the minimal slice of the real command tree +// where diagnostic exemption applies: root -> config -> policy -> show. +func configPolicyTree() *cobra.Command { + root := &cobra.Command{Use: "lark-cli"} + config := &cobra.Command{Use: "config"} + root.AddCommand(config) + policy := &cobra.Command{Use: "policy"} + config.AddCommand(policy) + policy.AddCommand(&cobra.Command{Use: "show", RunE: noop}) + // Plus an unrelated command that the Rule will deny, to anchor the + // "everything except diagnostics" check. + im := &cobra.Command{Use: "im"} + root.AddCommand(im) + im.AddCommand(&cobra.Command{Use: "+send", RunE: noop}) + return root +} + +func TestEvaluate_diagnosticAllowedDespiteStrictAllow(t *testing.T) { + root := configPolicyTree() + // Rule that allows ONLY docs/** -- normally locks out everything else. + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + }) + got := e.EvaluateAll(root) + + if !got["config/policy/show"].Allowed { + t.Errorf("config/policy/show must be unconditionally allowed; got Allowed=false reason=%q", + got["config/policy/show"].ReasonCode) + } + // Sanity: a non-diagnostic command is still denied so we know the + // rule itself is active. + if got["im/+send"].Allowed { + t.Errorf("im/+send should be denied by Allow=[docs/**]; got Allowed=true") + } +} + +func TestEvaluate_diagnosticAllowedDespiteExplicitDeny(t *testing.T) { + // Even a Rule that explicitly Denies the path must not lock the + // operator out -- diagnostic is a permanent hole. If a security- + // sensitive deployment needs to block introspection, they should + // strip the binary, not rely on Rule. + root := configPolicyTree() + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"**"}, + Deny: []string{"config/policy/**"}, + }) + got := e.EvaluateAll(root) + + if !got["config/policy/show"].Allowed { + t.Errorf("config/policy/show must override explicit Deny; got Allowed=false reason=%q", + got["config/policy/show"].ReasonCode) + } +} + +func TestIsDiagnosticPath(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"config/policy/show", true}, + {"config/plugins/show", true}, + {"config/policy", false}, // parent group itself is not exempt + {"config/plugins", false}, // parent group itself is not exempt + {"docs/+fetch", false}, + {"", false}, + } + for _, tc := range cases { + if got := cmdpolicy.IsDiagnosticPath(tc.path); got != tc.want { + t.Errorf("IsDiagnosticPath(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} diff --git a/internal/cmdpolicy/engine.go b/internal/cmdpolicy/engine.go new file mode 100644 index 000000000..c2e7e0162 --- /dev/null +++ b/internal/cmdpolicy/engine.go @@ -0,0 +1,392 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package cmdpolicy is the user-layer command policy engine. It consumes a +// platform.Rule and the cobra command tree, evaluates each runnable command +// against the rule's four-axis filter (Allow / Deny / MaxRisk / Identities), +// and produces a path -> Decision map. A separate BuildDeniedByPath step +// converts those leaf decisions into a deniedByPath map (with parent-group +// aggregation), which the Apply step consumes to install denyStubs. +// +// This package only implements the user-layer half. Strict-mode is handled +// by cmd/prune.go, which produces command_denied envelopes of the same +// shape via BuildDenialError so external agents can dispatch on +// detail.layer / reason_code uniformly regardless of which layer rejected +// the call. +package cmdpolicy + +import ( + "fmt" + + "github.com/bmatcuk/doublestar/v4" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdmeta" +) + +// Decision is the user-layer single-rule evaluation result. Distinct from +// Denial: Decision carries Allowed=true/false and the +// rejection reason when Allowed=false; Denial only ever exists when the +// command is rejected. Keeping them separate avoids a perpetually-false +// Allowed field on Denial. +type Decision struct { + Allowed bool + ReasonCode string // "" when Allowed=true + Reason string // human-readable +} + +// Engine evaluates a Rule against the command tree. It is stateless except +// for the Rule snapshot it was constructed with. +type Engine struct { + rule *platform.Rule +} + +// New returns an Engine bound to a Rule. A nil Rule means "no user-layer +// restriction" -- EvaluateOne always returns Allowed=true. +func New(rule *platform.Rule) *Engine { + return &Engine{rule: rule} +} + +// EvaluateAll walks the command tree and evaluates every **runnable** +// command against the Rule. Pure parent groups (no RunE) are deliberately +// skipped here: their decision is derived from children by +// BuildDeniedByPath. Evaluating groups directly would incorrectly deny +// "docs" under an Allow:["docs/**"] rule (the group's own path "docs" +// does not match the "**"-requiring glob). +// +// Hybrid commands (own RunE plus children) are evaluated as ordinary +// leaves here; the aggregation pass treats them specially. +func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision { + out := map[string]Decision{} + walkTree(root, func(c *cobra.Command) { + if !c.Runnable() { + return + } + // Pure parent groups carrying the AnnotationPureGroup marker + // (installed by cmd.installUnknownSubcommandGuard) look + // Runnable to cobra but are not a real leaf: skip them just + // like cobra-native parent groups, so a user-level Rule does + // not block ` --help` discovery. + if IsPureGroup(c) { + return + } + path := CanonicalPath(c) + if path == "" { + return + } + out[path] = e.EvaluateOne(c) + }) + return out +} + +// EvaluateOne returns the user-layer decision for a single command. Always +// Allowed=true when the engine has no Rule. +func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision { + if e.rule == nil { + return Decision{Allowed: true} + } + r := e.rule + path := CanonicalPath(cmd) + + if IsDiagnosticPath(path) { + return Decision{Allowed: true} + } + + // A registered Rule expresses intent over the closed risk taxonomy + // (read / write / high-risk-write). Two ways a command can fall + // outside that taxonomy: + // + // - "absent" (no risk_level annotation) — fail-closed by default, + // but Rule.AllowUnannotated=true opts out for gradual adoption. + // - "invalid" (annotation exists but is a typo / not in the + // closed enum) — always fail-closed regardless of + // AllowUnannotated. Typo is a code bug, not a migration phase. + cmdRiskStr, hasRisk := cmdmeta.Risk(cmd) + cmdRisk := platform.Risk(cmdRiskStr) + var ( + cmdRank int + cmdRankOk bool + ) + if hasRisk { + cmdRank, cmdRankOk = cmdRisk.Rank() + if !cmdRankOk { + return Decision{ + Allowed: false, + ReasonCode: "risk_invalid", + Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)), + } + } + } else if !r.AllowUnannotated { + return Decision{ + Allowed: false, + ReasonCode: "risk_not_annotated", + Reason: "command has no risk_level annotation; rule denies unannotated commands", + } + } + + // Axis 1: Deny has priority. + if matched, ok := firstMatch(r.Deny, path); ok { + return Decision{ + Allowed: false, + ReasonCode: "command_denylisted", + Reason: fmt.Sprintf("command path %q matched deny pattern %q", path, matched), + } + } + + // Axis 2: Allow gate (empty allow means "no restriction"). + if len(r.Allow) > 0 && !matchesAny(r.Allow, path) { + return Decision{ + Allowed: false, + ReasonCode: "domain_not_allowed", + Reason: fmt.Sprintf("command path %q not in allow list %v", path, r.Allow), + } + } + + // Axis 3: MaxRisk. Skipped when cmd risk is absent + AllowUnannotated: + // the engine has no rank to compare against, and AllowUnannotated + // is the explicit "allow this through" opt-in. + if r.MaxRisk != "" && cmdRankOk { + if limit, limitOk := r.MaxRisk.Rank(); limitOk && cmdRank > limit { + return Decision{ + Allowed: false, + ReasonCode: reasonCodeForRisk(cmdRisk), + Reason: fmt.Sprintf("command risk %q exceeds rule max_risk %q", cmdRisk, r.MaxRisk), + } + } + } + + // Axis 4: Identities. Unknown command identities is treated as ALLOW. + if len(r.Identities) > 0 { + cmdIdents := cmdmeta.Identities(cmd) + if cmdIdents != nil && !hasIdentityIntersection(r.Identities, cmdIdents) { + return Decision{ + Allowed: false, + ReasonCode: "identity_mismatch", + Reason: fmt.Sprintf("command supports identities %v; rule allows %v", cmdIdents, r.Identities), + } + } + } + + return Decision{Allowed: true} +} + +// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed +// by canonical path. It performs the parent-group aggregation defined in +// the tech doc: a non-runnable parent whose every runnable descendant is +// denied gets an aggregate denial (via AggregateChildren); +// hybrid commands (own RunE + children) get one only when both their own +// RunE and all children are denied. +// +// The root command (no parent) is never installed with a denyStub even if +// every child is denied -- the binary entry point must remain dispatchable +// so `--help` and similar remain available. +// +// source / ruleName populate PolicySource and RuleName on the produced +// Denial values, so envelope output can attribute denials. +func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial { + out := map[string]Denial{} + + sourceLabel := policySourceLabel(source) + for path, d := range decisions { + if !d.Allowed { + out[path] = Denial{ + Layer: LayerPolicy, + PolicySource: sourceLabel, + RuleName: ruleName, + ReasonCode: d.ReasonCode, + Reason: d.Reason, + } + } + } + + aggregateParents(root, out) + return out +} + +// aggregateParents recursively examines each parent group. Returns true +// when every runnable descendant beneath cmd (including cmd itself when +// runnable) is denied; in that case the function also inserts an aggregate +// Denial for cmd, unless cmd is the binary root or cmd is already in the +// map (own RunE denial preserved). +// +// "Live" children are those with at least one runnable descendant; pure +// non-runnable placeholders neither count toward "all denied" nor block +// the aggregation. +func aggregateParents(cmd *cobra.Command, denied map[string]Denial) bool { + if cmd == nil { + return false + } + + children := cmd.Commands() + // A pure parent group decorated with the unknown-subcommand guard + // looks Runnable() to cobra but is not a true hybrid: treat it + // exactly like cobra-native parent groups so the aggregation pass + // can still install an aggregate deny stub when every live child + // is denied. + cmdRunnable := cmd.Runnable() && !IsPureGroup(cmd) + cmdPath := CanonicalPath(cmd) + + // Pure leaf + if len(children) == 0 { + if !cmdRunnable { + return false // placeholder, doesn't contribute + } + _, ok := denied[cmdPath] + return ok + } + + // Has children: recurse first, collect direct-child denials for the + // aggregation message. + childDenials := make([]ChildDenial, 0, len(children)) + liveChildSeen := false + allLiveChildrenDenied := true + for _, child := range children { + childDenied := aggregateParents(child, denied) + if hasRunnableDescendant(child) { + liveChildSeen = true + if !childDenied { + allLiveChildrenDenied = false + } + } + if cp := CanonicalPath(child); cp != "" { + if d, ok := denied[cp]; ok { + childDenials = append(childDenials, ChildDenial{Path: cp, Denial: d}) + } + } + } + + if !liveChildSeen { + // No reachable runnable descendant in children, but cmd itself + // may still be a runnable hybrid (own RunE + placeholder + // children). The contract is "every runnable descendant + // beneath cmd (including cmd itself when runnable) is denied", + // so when cmd is runnable, the answer depends on whether cmd + // itself was denied. Returning false unconditionally here lost + // that signal and blocked aggregation up the chain. + if cmdRunnable { + _, ownDenied := denied[cmdPath] + return ownDenied + } + return false + } + + // Hybrid: own RunE must also be denied for the group to count as denied. + if cmdRunnable { + if _, ownDenied := denied[cmdPath]; !ownDenied { + return false + } + } + + if !allLiveChildrenDenied { + return false + } + + // Everything reachable below this command is denied. Install the + // aggregate denyStub if there isn't already an own denial here, and + // skip the binary root. + if cmd.HasParent() && cmdPath != "" { + if _, exists := denied[cmdPath]; !exists { + SortChildren(childDenials) + denied[cmdPath] = AggregateChildren(childDenials) + } + } + return true +} + +// hasRunnableDescendant reports whether cmd or any descendant has RunE. +// We use it to ignore pure placeholder branches when aggregating. +func hasRunnableDescendant(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + if cmd.Runnable() && !IsPureGroup(cmd) { + return true + } + for _, c := range cmd.Commands() { + if hasRunnableDescendant(c) { + return true + } + } + return false +} + +// policySourceLabel produces the "plugin:foo" / "yaml" / "" label that goes +// into CommandDeniedError.PolicySource and envelope.detail.policy_source. +// +// **Plugin name is included** because plugins live inside the binary and +// their names are part of the implementation contract; an integrator +// debugging a denial wants to know which plugin's Restrict() fired. +// +// **YAML file path is deliberately omitted** -- the envelope is observable +// by agents, CI logs, and other downstream systems, and the path leaks +// the user's home directory (e.g. /Users/alice/.lark-cli/policy.yml). +// The Denial.RuleName field already carries the human-identifier the user +// chose for their rule (yaml's "name:" field), which suffices for +// disambiguation. Use `config policy show` if the absolute path matters +// for a local debugging session. +func policySourceLabel(s ResolveSource) string { + switch s.Kind { + case SourcePlugin: + return "plugin:" + s.Name + case SourceYAML: + return "yaml" + } + return "" +} + +// reasonCodeForRisk picks the canonical reason_code for an exceeds-max-risk +// rejection. +func reasonCodeForRisk(risk platform.Risk) string { + if risk == platform.RiskWrite || risk == platform.RiskHighRiskWrite { + return "write_not_allowed" + } + return "risk_too_high" +} + +// matchesAny reports whether path matches any of the doublestar globs. +// Invalid globs are skipped here -- they're rejected upstream by +// ValidateRule when the rule first enters the system. +func matchesAny(globs []string, path string) bool { + _, ok := firstMatch(globs, path) + return ok +} + +// firstMatch returns the first glob in globs that matches path. Used by +// command_denylisted so the envelope can name the specific deny pattern +// that fired. +func firstMatch(globs []string, path string) (string, bool) { + for _, g := range globs { + if ok, err := doublestar.Match(g, path); err == nil && ok { + return g, true + } + } + return "", false +} + +// hasIdentityIntersection reports whether the rule's typed identities +// share any value with the command's raw identity strings. Both slices +// are short (usually 1-2 identities) so a nested loop beats allocating +// a set. +func hasIdentityIntersection(rule []platform.Identity, cmd []string) bool { + for _, x := range rule { + for _, y := range cmd { + if string(x) == y { + return true + } + } + } + return false +} + +// walkTree applies fn to every command in the tree, depth-first. Hidden +// commands are visited too -- they can still be invoked. +func walkTree(root *cobra.Command, fn func(*cobra.Command)) { + if root == nil { + return + } + fn(root) + for _, c := range root.Commands() { + walkTree(c, fn) + } +} diff --git a/internal/cmdpolicy/engine_test.go b/internal/cmdpolicy/engine_test.go new file mode 100644 index 000000000..e2e9ca559 --- /dev/null +++ b/internal/cmdpolicy/engine_test.go @@ -0,0 +1,505 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdmeta" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" +) + +// buildTree assembles a tiny realistic tree for engine tests: +// +// lark-cli (root) +// ├── docs +// │ ├── +fetch risk=read identities=[user,bot] +// │ ├── +update risk=write identities=[user] +// │ └── +delete-doc risk=high-risk-write +// └── im +// └── +send risk=write identities=[bot] +func buildTree() *cobra.Command { + root := &cobra.Command{Use: "lark-cli"} + + docs := &cobra.Command{Use: "docs"} + cmdmeta.SetDomain(docs, "docs") + root.AddCommand(docs) + + fetch := &cobra.Command{Use: "+fetch", RunE: noop} + cmdutil.SetRisk(fetch, "read") + cmdutil.SetSupportedIdentities(fetch, []string{"user", "bot"}) + docs.AddCommand(fetch) + + update := &cobra.Command{Use: "+update", RunE: noop} + cmdutil.SetRisk(update, "write") + cmdutil.SetSupportedIdentities(update, []string{"user"}) + docs.AddCommand(update) + + deleteDoc := &cobra.Command{Use: "+delete-doc", RunE: noop} + cmdutil.SetRisk(deleteDoc, "high-risk-write") + docs.AddCommand(deleteDoc) + + im := &cobra.Command{Use: "im"} + cmdmeta.SetDomain(im, "im") + root.AddCommand(im) + + send := &cobra.Command{Use: "+send", RunE: noop} + cmdutil.SetRisk(send, "write") + cmdutil.SetSupportedIdentities(send, []string{"bot"}) + im.AddCommand(send) + + return root +} + +func noop(*cobra.Command, []string) error { return nil } + +func TestEvaluate_nilRuleAllowsAll(t *testing.T) { + root := buildTree() + got := cmdpolicy.New(nil).EvaluateAll(root) + for path, d := range got { + if !d.Allowed { + t.Fatalf("nil rule should allow all, got Allowed=false for %s", path) + } + } +} + +func TestEvaluate_allowGlob(t *testing.T) { + root := buildTree() + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + }) + got := e.EvaluateAll(root) + + if !got["docs/+fetch"].Allowed { + t.Errorf("docs/+fetch should be allowed by docs/** glob") + } + if got["im/+send"].Allowed { + t.Errorf("im/+send should NOT be allowed when Allow=docs/**") + } + if got["im/+send"].ReasonCode != "domain_not_allowed" { + t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed", + got["im/+send"].ReasonCode) + } +} + +func TestEvaluate_denyTakesPriorityOverAllow(t *testing.T) { + root := buildTree() + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + Deny: []string{"docs/+delete-doc"}, + }) + got := e.EvaluateAll(root) + + if got["docs/+delete-doc"].Allowed { + t.Errorf("docs/+delete-doc should be denied by Deny rule") + } + if got["docs/+delete-doc"].ReasonCode != "command_denylisted" { + t.Errorf("ReasonCode = %q, want command_denylisted", + got["docs/+delete-doc"].ReasonCode) + } + if !got["docs/+fetch"].Allowed { + t.Errorf("docs/+fetch should still be allowed (not in Deny)") + } +} + +func TestEvaluate_maxRiskCutoff(t *testing.T) { + root := buildTree() + e := cmdpolicy.New(&platform.Rule{ + MaxRisk: "write", // allow read+write, deny high-risk-write + }) + got := e.EvaluateAll(root) + + if !got["docs/+update"].Allowed { + t.Errorf("+update (risk=write) should pass MaxRisk=write") + } + if !got["docs/+fetch"].Allowed { + t.Errorf("+fetch (risk=read) should pass MaxRisk=write") + } + if got["docs/+delete-doc"].Allowed { + t.Errorf("+delete-doc (risk=high-risk-write) should fail MaxRisk=write") + } + if rc := got["docs/+delete-doc"].ReasonCode; rc != "write_not_allowed" { + t.Errorf("ReasonCode = %q, want write_not_allowed", rc) + } +} + +// Unannotated commands are implicit-deny when any Rule is registered. +// The closed risk taxonomy (read / write / high-risk-write) is the only +// vocabulary a Rule can reason about; an unannotated command falls +// outside that vocabulary and is denied with reason_code +// "risk_not_annotated", regardless of whether the rule sets MaxRisk. +func TestEvaluate_unannotatedRiskIsDeny(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + // Note: no SetRisk on this command -> unannotated + orphan := &cobra.Command{Use: "+orphan", RunE: noop} + docs.AddCommand(orphan) + + // Rule without MaxRisk still triggers the implicit deny. + e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) + got := e.EvaluateAll(root) + if got["docs/+orphan"].Allowed { + t.Fatalf("unannotated risk must be denied when a Rule is registered") + } + if got["docs/+orphan"].ReasonCode != "risk_not_annotated" { + t.Errorf("ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode) + } + + // And with MaxRisk it still uses risk_not_annotated (the missing- + // annotation gate runs before the MaxRisk axis). + e = cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + got = e.EvaluateAll(root) + if got["docs/+orphan"].ReasonCode != "risk_not_annotated" { + t.Errorf("ReasonCode under MaxRisk = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode) + } + + // An empty Rule{} (no Allow / Deny / MaxRisk / Identities) still + // triggers the implicit deny. "any registered Rule = enter the safety + // boundary" is the design contract; pin it so future edits cannot + // silently weaken it. + e = cmdpolicy.New(&platform.Rule{}) + got = e.EvaluateAll(root) + if got["docs/+orphan"].Allowed { + t.Fatalf("empty Rule{} must still deny unannotated commands") + } + if got["docs/+orphan"].ReasonCode != "risk_not_annotated" { + t.Errorf("empty Rule{} ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode) + } + + // Without any Rule, unannotated commands are still allowed (no + // policy engine is invoked when no plugin registers a Rule). + e = cmdpolicy.New(nil) + got = e.EvaluateAll(root) + if !got["docs/+orphan"].Allowed { + t.Fatalf("nil Rule must allow unannotated commands (no main-flow impact)") + } +} + +// AllowUnannotated=true opts out of the "unannotated = deny" rule for +// gradual adoption. The flag does NOT loosen any other axis: Deny still +// rejects, MaxRisk is skipped (no rank to compare), Allow/Identities still +// apply. +func TestEvaluate_allowUnannotatedOptsOutOfDeny(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + orphan := &cobra.Command{Use: "+orphan", RunE: noop} + docs.AddCommand(orphan) + + // Without opt-in: still denied + e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) + if got := e.EvaluateAll(root); got["docs/+orphan"].Allowed { + t.Fatalf("default behaviour must deny unannotated; AllowUnannotated should be opt-in") + } + + // With opt-in: allowed + e = cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + AllowUnannotated: true, + }) + got := e.EvaluateAll(root) + if !got["docs/+orphan"].Allowed { + t.Fatalf("AllowUnannotated=true must allow unannotated commands; got %+v", got["docs/+orphan"]) + } + + // AllowUnannotated does NOT bypass Deny: an unannotated command + // hitting a Deny glob is still rejected. + e = cmdpolicy.New(&platform.Rule{ + Deny: []string{"docs/+orphan"}, + AllowUnannotated: true, + }) + got = e.EvaluateAll(root) + if got["docs/+orphan"].Allowed { + t.Fatalf("AllowUnannotated must not bypass Deny; got %+v", got["docs/+orphan"]) + } + if got["docs/+orphan"].ReasonCode != "command_denylisted" { + t.Errorf("ReasonCode under Deny+AllowUnannotated = %q, want command_denylisted", + got["docs/+orphan"].ReasonCode) + } +} + +// risk_invalid (typo) is unaffected by AllowUnannotated and emits a +// "did you mean" suggestion in the reason text. +func TestEvaluate_invalidRiskAlwaysDeny_andSuggests(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + typo := &cobra.Command{Use: "+typo", RunE: noop} + cmdutil.SetRisk(typo, "wrtie") + docs.AddCommand(typo) + + // AllowUnannotated=true must NOT bypass risk_invalid — typo is a + // code bug, not a missing annotation. + e := cmdpolicy.New(&platform.Rule{ + MaxRisk: "read", + AllowUnannotated: true, + }) + got := e.EvaluateAll(root) + if got["docs/+typo"].Allowed { + t.Fatalf("AllowUnannotated must not bypass risk_invalid; got %+v", got["docs/+typo"]) + } + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode) + } + if !strings.Contains(got["docs/+typo"].Reason, "write") { + t.Errorf("Reason should contain suggestion 'write', got %q", got["docs/+typo"].Reason) + } +} + +// Invalid risk annotations (typos like "wrtie" or anything outside the +// read|write|high-risk-write taxonomy) are denied with reason_code +// "risk_invalid". Without this gate they used to pass the MaxRisk axis +// because RiskRank returned ok=false and the comparison was skipped -- +// a typo SetRisk would silently slip past an "agent read-only" rule. +func TestEvaluate_invalidRiskIsDeny(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + typo := &cobra.Command{Use: "+typo", RunE: noop} + cmdutil.SetRisk(typo, "wrtie") // typo for "write" + docs.AddCommand(typo) + + // Even under MaxRisk=read the typo command must not slip through. + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + got := e.EvaluateAll(root) + if got["docs/+typo"].Allowed { + t.Fatalf("invalid risk must be denied under MaxRisk=read, got allowed") + } + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode) + } + + // Same when no MaxRisk is set -- the taxonomy check runs unconditionally + // once a Rule is present. + e = cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) + got = e.EvaluateAll(root) + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode without MaxRisk = %q, want risk_invalid", got["docs/+typo"].ReasonCode) + } + + // The risk_invalid gate must fire BEFORE Deny matching, otherwise a + // typo command landing in the deny list would surface as + // command_denylisted and mask the underlying taxonomy violation. + e = cmdpolicy.New(&platform.Rule{Deny: []string{"docs/+typo"}}) + got = e.EvaluateAll(root) + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode under Deny match = %q, want risk_invalid (taxonomy gate must precede Deny)", got["docs/+typo"].ReasonCode) + } + + // Without any Rule, invalid risk is not policed (same main-flow + // no-impact rule as risk_not_annotated). + e = cmdpolicy.New(nil) + got = e.EvaluateAll(root) + if !got["docs/+typo"].Allowed { + t.Fatalf("nil Rule must allow invalid risk (no main-flow impact)") + } +} + +func TestEvaluate_identitiesIntersection(t *testing.T) { + root := buildTree() + e := cmdpolicy.New(&platform.Rule{ + Identities: []platform.Identity{"bot"}, // bot-only rule + }) + got := e.EvaluateAll(root) + + // docs/+fetch has [user, bot] -- intersection includes bot -> ALLOW + if !got["docs/+fetch"].Allowed { + t.Errorf("+fetch (identities=user,bot) should intersect bot rule") + } + // docs/+update has [user] -- no intersection with bot -> DENY + if got["docs/+update"].Allowed { + t.Errorf("+update (identities=user) should fail bot-only rule") + } + if got["docs/+update"].ReasonCode != "identity_mismatch" { + t.Errorf("ReasonCode = %q, want identity_mismatch", + got["docs/+update"].ReasonCode) + } +} + +// Reason strings must carry both the attempted value and the rule's +// constraint so the envelope is self-contained for AI consumers. +// Asserting on substrings (not exact match) leaves room for minor wording +// tweaks while pinning the value-carrying behaviour. +func TestEvaluate_reasonCarriesAttemptAndConstraint(t *testing.T) { + root := buildTree() + + cases := []struct { + name string + rule *platform.Rule + path string + wantInReason []string + }{ + { + name: "identity_mismatch surfaces both identity sets", + rule: &platform.Rule{Identities: []platform.Identity{"bot"}}, + path: "docs/+update", // identities=[user] + wantInReason: []string{"[user]", "[bot]"}, + }, + { + name: "domain_not_allowed surfaces path and allow list", + rule: &platform.Rule{Allow: []string{"docs/**"}}, + path: "im/+send", + wantInReason: []string{`"im/+send"`, "docs/**"}, + }, + { + name: "command_denylisted surfaces matched deny pattern", + rule: &platform.Rule{Deny: []string{"docs/+delete-*"}}, + path: "docs/+delete-doc", + wantInReason: []string{`"docs/+delete-doc"`, `"docs/+delete-*"`}, + }, + { + name: "risk_too_high surfaces cmd risk and max_risk", + rule: &platform.Rule{MaxRisk: "write"}, + path: "docs/+delete-doc", // risk=high-risk-write + wantInReason: []string{`"high-risk-write"`, `"write"`}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := cmdpolicy.New(tc.rule).EvaluateAll(root) + d, ok := got[tc.path] + if !ok { + t.Fatalf("no decision for %q", tc.path) + } + if d.Allowed { + t.Fatalf("%q should have been denied", tc.path) + } + for _, sub := range tc.wantInReason { + if !strings.Contains(d.Reason, sub) { + t.Errorf("reason %q missing %q", d.Reason, sub) + } + } + }) + } +} + +// Unknown identities defaults to ALLOW. A command with risk annotated +// but without supportedIdentities passes any identity filter. +func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + cmd := &cobra.Command{Use: "+x", RunE: noop} + cmdutil.SetRisk(cmd, "read") + root.AddCommand(cmd) + // no SetSupportedIdentities + + e := cmdpolicy.New(&platform.Rule{Identities: []platform.Identity{"bot"}}) + got := e.EvaluateAll(root) + if !got["+x"].Allowed { + t.Fatalf("unknown identities must pass any identity rule") + } +} + +// Apply must install denyStubs only on Layer="policy" entries. A +// "strict_mode" denial in the same map must be left for +// applyStrictModeDenials in cmd/. +func TestApply_onlyTouchesPruningLayer(t *testing.T) { + root := buildTree() + denied := map[string]cmdpolicy.Denial{ + "docs/+update": {Layer: "policy", ReasonCode: "write_not_allowed"}, + "docs/+fetch": {Layer: "strict_mode", ReasonCode: "identity_not_supported"}, + } + + count := cmdpolicy.Apply(root, denied) + if count != 1 { + t.Fatalf("Apply count = %d, want 1 (only pruning-layer entries)", count) + } + + update := findChild(t, root, "docs", "+update") + if !update.Hidden { + t.Errorf("+update should be Hidden after Apply") + } + if !update.DisableFlagParsing { + t.Errorf("+update should have DisableFlagParsing=true (constraint #4)") + } + + // strict-mode entry must NOT have been touched here. + fetch := findChild(t, root, "docs", "+fetch") + if fetch.Hidden || fetch.DisableFlagParsing { + t.Errorf("+fetch (strict_mode layer) should NOT be touched by cmdpolicy.Apply") + } +} + +// Calling the denied RunE must produce a typed CommandDeniedError with the +// right Layer/ReasonCode. This is the contract every external consumer +// (agent, integration) depends on. +func TestApply_runEReturnsTypedError(t *testing.T) { + root := buildTree() + cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{ + "docs/+update": { + Layer: "policy", + PolicySource: "plugin:secaudit", + RuleName: "secaudit-policy", + ReasonCode: "write_not_allowed", + Reason: "write disabled", + }, + }) + + update := findChild(t, root, "docs", "+update") + err := update.RunE(update, []string{}) + if err == nil { + t.Fatalf("denied command should return error") + } + var denied *platform.CommandDeniedError + if !errors.As(err, &denied) { + t.Fatalf("error should be *platform.CommandDeniedError, got %T", err) + } + if denied.Layer != "policy" || denied.ReasonCode != "write_not_allowed" { + t.Errorf("denial = %+v, want layer=pruning code=write_not_allowed", denied) + } + if denied.Path != "docs/+update" { + t.Errorf("Path = %q, want docs/+update", denied.Path) + } + if denied.PolicySource != "plugin:secaudit" || denied.RuleName != "secaudit-policy" { + t.Errorf("policy source / rule name lost in stub: %+v", denied) + } +} + +func TestApply_emptyMapNoop(t *testing.T) { + root := buildTree() + if got := cmdpolicy.Apply(root, nil); got != 0 { + t.Fatalf("nil deniedByPath should yield count=0, got %d", got) + } +} + +// CanonicalPath strips the root and joins with slashes -- the form +// doublestar globs need to work. +func TestCanonicalPath(t *testing.T) { + root := buildTree() + update := findChild(t, root, "docs", "+update") + if got := cmdpolicy.CanonicalPath(update); got != "docs/+update" { + t.Fatalf("CanonicalPath = %q, want docs/+update", got) + } + if got := cmdpolicy.CanonicalPath(root); got != "lark-cli" { + t.Fatalf("CanonicalPath(root) = %q, want lark-cli (orphan fallback)", got) + } +} + +// findChild is a test helper: descend a path of cmd.Use names through the +// tree, failing the test if any step is missing. +func findChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command { + t.Helper() + cur := parent + for _, n := range names { + var next *cobra.Command + for _, c := range cur.Commands() { + if c.Use == n { + next = c + break + } + } + if next == nil { + t.Fatalf("child %q not found under %q", n, cur.Use) + } + cur = next + } + return cur +} diff --git a/internal/cmdpolicy/path.go b/internal/cmdpolicy/path.go new file mode 100644 index 000000000..fe0124db2 --- /dev/null +++ b/internal/cmdpolicy/path.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "strings" + + "github.com/spf13/cobra" +) + +// CanonicalPath returns the rootless slash-separated path used everywhere in +// the pruning framework. Cobra's CommandPath() yields space-separated +// segments ("lark-cli docs +update"); doublestar globs ("docs/**") require +// slashes, so all internal lookups go through this conversion. +func CanonicalPath(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + parts := make([]string, 0, 4) + for c := cmd; c != nil && c.HasParent(); c = c.Parent() { + parts = append(parts, useName(c)) + } + for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { + parts[i], parts[j] = parts[j], parts[i] + } + if len(parts) == 0 { + return useName(cmd) + } + return strings.Join(parts, "/") +} + +func useName(cmd *cobra.Command) string { + name := cmd.Use + if i := strings.IndexByte(name, ' '); i >= 0 { + name = name[:i] + } + return name +} diff --git a/internal/cmdpolicy/resolver.go b/internal/cmdpolicy/resolver.go new file mode 100644 index 000000000..d70335f58 --- /dev/null +++ b/internal/cmdpolicy/resolver.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "errors" + "fmt" + "os" + + "github.com/larksuite/cli/extension/platform" + pyaml "github.com/larksuite/cli/internal/cmdpolicy/yaml" + "github.com/larksuite/cli/internal/vfs" +) + +type SourceKind string + +const ( + SourcePlugin SourceKind = "plugin" + SourceYAML SourceKind = "yaml" + SourceNone SourceKind = "none" +) + +type ResolveSource struct { + Kind SourceKind + Name string +} + +type PluginRule struct { + PluginName string + Rule *platform.Rule +} + +type Sources struct { + PluginRules []PluginRule + YAMLRule *platform.Rule + YAMLPath string +} + +var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted") + +// Resolve picks by precedence: plugin > yaml > none. Pure function; load +// yaml via LoadYAMLPolicy first. Winner is validated. +func Resolve(s Sources) (*platform.Rule, ResolveSource, error) { + if len(s.PluginRules) > 1 { + names := make([]string, len(s.PluginRules)) + for i, pr := range s.PluginRules { + names[i] = pr.PluginName + } + return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names) + } + + if len(s.PluginRules) == 1 { + pr := s.PluginRules[0] + if err := ValidateRule(pr.Rule); err != nil { + return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err) + } + return pr.Rule, ResolveSource{Kind: SourcePlugin, Name: pr.PluginName}, nil + } + + if s.YAMLRule != nil { + if err := ValidateRule(s.YAMLRule); err != nil { + return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err) + } + return s.YAMLRule, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil + } + + return nil, ResolveSource{Kind: SourceNone}, nil +} + +// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent, +// so callers can pass the result straight into Sources.YAMLRule. +func LoadYAMLPolicy(path string) (*platform.Rule, error) { + if path == "" { + return nil, nil + } + if _, err := vfs.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("stat policy yaml %q: %w", path, err) + } + data, err := vfs.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read policy yaml %q: %w", path, err) + } + rule, err := pyaml.Parse(data) + if err != nil { + return nil, fmt.Errorf("policy yaml %q: %w", path, err) + } + return rule, nil +} diff --git a/internal/cmdpolicy/resolver_test.go b/internal/cmdpolicy/resolver_test.go new file mode 100644 index 000000000..1631cb6c7 --- /dev/null +++ b/internal/cmdpolicy/resolver_test.go @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" +) + +func TestResolve_singlePluginWins(t *testing.T) { + rule := &platform.Rule{Name: "secaudit"} + got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: rule}}, + }) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" { + t.Fatalf("Resolve = (%v, %+v)", got, src) + } +} + +func TestResolve_pluginShadowsYaml(t *testing.T) { + pluginRule := &platform.Rule{Name: "from-plugin"} + yamlRule := &platform.Rule{Name: "from-yaml"} + got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}}, + YAMLRule: yamlRule, + YAMLPath: "/some/policy.yml", + }) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got.Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin { + t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src) + } +} + +func TestResolve_yamlWhenNoPlugin(t *testing.T) { + yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"} + got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + YAMLRule: yamlRule, + YAMLPath: "/some/policy.yml", + }) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML { + t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src) + } + if src.Name != "/some/policy.yml" { + t.Errorf("yaml source Name should carry path, got %q", src.Name) + } +} + +func TestResolve_emptyEverythingIsNone(t *testing.T) { + got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{}) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got != nil || src.Kind != cmdpolicy.SourceNone { + t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src) + } +} + +// Two plugins both contributing a Rule must produce the typed error so +// the bootstrap pipeline aborts (hard-constraint #7). +func TestResolve_multipleRestrictIsError(t *testing.T) { + _, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{ + PluginRules: []cmdpolicy.PluginRule{ + {PluginName: "a", Rule: &platform.Rule{Name: "a"}}, + {PluginName: "b", Rule: &platform.Rule{Name: "b"}}, + }, + }) + if !errors.Is(err, cmdpolicy.ErrMultipleRestricts) { + t.Fatalf("err = %v, want ErrMultipleRestricts", err) + } +} + +// LoadYAMLPolicy: missing file returns (nil, nil) silently so callers +// can pass the result straight into Sources.YAMLRule without special- +// casing not-exist. +func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent-policy.yml") + rule, err := cmdpolicy.LoadYAMLPolicy(missing) + if err != nil { + t.Fatalf("missing yaml should not error, got %v", err) + } + if rule != nil { + t.Fatalf("missing yaml should return nil rule, got %+v", rule) + } +} + +func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) { + rule, err := cmdpolicy.LoadYAMLPolicy("") + if err != nil { + t.Fatalf("empty path should not error, got %v", err) + } + if rule != nil { + t.Fatalf("empty path should return nil rule, got %+v", rule) + } +} + +func TestLoadYAMLPolicy_parsesValid(t *testing.T) { + dir := t.TempDir() + yamlPath := filepath.Join(dir, "policy.yml") + if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil { + t.Fatalf("write yaml: %v", err) + } + rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath) + if err != nil { + t.Fatalf("LoadYAMLPolicy err: %v", err) + } + if rule == nil || rule.Name != "from-yaml" { + t.Fatalf("expected rule with name=from-yaml, got %+v", rule) + } +} diff --git a/internal/cmdpolicy/source_label_test.go b/internal/cmdpolicy/source_label_test.go new file mode 100644 index 000000000..dbd31d560 --- /dev/null +++ b/internal/cmdpolicy/source_label_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/output" +) + +// The envelope's policy_source must never leak the absolute home path. +// "yaml:/Users/alice/.lark-cli/policy.yml" would expose Alice's username +// to any agent or log consumer; the contract is to emit just "yaml" and +// rely on rule_name (from the yaml's "name:" field) for disambiguation. +func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + leaf := &cobra.Command{Use: "+write", RunE: func(*cobra.Command, []string) error { return nil }} + docs.AddCommand(leaf) + + e := cmdpolicy.New(&platform.Rule{ + Name: "my-readonly-rule", + Allow: []string{"contact/**"}, // docs/* falls outside, denied + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{ + Kind: cmdpolicy.SourceYAML, + Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path + }, "my-readonly-rule") + + cmdpolicy.Apply(root, denied) + err := leaf.RunE(leaf, nil) + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected denial ExitError, got %v", err) + } + detail := exitErr.Detail.Detail.(map[string]any) + src, _ := detail["policy_source"].(string) + if src != "yaml" { + t.Errorf("policy_source = %q, want %q (no path leak)", src, "yaml") + } + // rule_name carries the disambiguating identifier. + if detail["rule_name"] != "my-readonly-rule" { + t.Errorf("rule_name = %v, want my-readonly-rule", detail["rule_name"]) + } + // Direct probe: the absolute path must not appear anywhere in the + // envelope detail (key OR value). + for k, v := range detail { + if strings.Contains(k, "/Users/alice") || strings.Contains(asString(v), "/Users/alice") { + t.Errorf("envelope detail must not leak '/Users/alice', found in %s = %v", k, v) + } + } +} + +// Plugin name IS allowed in policy_source because plugins are in-binary +// and their names are part of the contract (an integrator debugging a +// denial wants to know which plugin fired). This test pins that intent +// so a future change does not silently strip the plugin name too. +func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + leaf := &cobra.Command{Use: "+block", RunE: func(*cobra.Command, []string) error { return nil }} + root.AddCommand(leaf) + + e := cmdpolicy.New(&platform.Rule{ + Name: "secaudit-policy", + Deny: []string{"+block"}, + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, + "secaudit-policy") + cmdpolicy.Apply(root, denied) + + err := leaf.RunE(leaf, nil) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError") + } + detail := exitErr.Detail.Detail.(map[string]any) + if detail["policy_source"] != "plugin:secaudit" { + t.Errorf("policy_source = %v, want plugin:secaudit", detail["policy_source"]) + } +} + +func asString(v any) string { + s, _ := v.(string) + return s +} diff --git a/internal/cmdpolicy/strict_mode_skip_test.go b/internal/cmdpolicy/strict_mode_skip_test.go new file mode 100644 index 000000000..90276cab5 --- /dev/null +++ b/internal/cmdpolicy/strict_mode_skip_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdpolicy" +) + +// cmdpolicy.Apply MUST NOT overwrite the denial annotation on a command +// already marked as strict-mode denied. strict-mode is a hard boundary +// (credential-derived); a user-layer rule cannot relabel or replace +// the error path. +// +// Without this invariant: when a user yaml rule happened to match the +// path of a strict-mode stub, Apply would change layer=strict_mode to +// layer=pruning, and the user-visible error would say "denied by yaml" +// instead of "strict mode". The hard-boundary contract demands +// strict_mode wins. +func TestApply_PreservesStrictModeAnnotation(t *testing.T) { + root := &cobra.Command{Use: "root"} + stub := &cobra.Command{ + Use: "victim", + Hidden: true, + Annotations: map[string]string{ + cmdpolicy.AnnotationDenialLayer: cmdpolicy.LayerStrictMode, + cmdpolicy.AnnotationDenialSource: "strict-mode", + }, + RunE: func(*cobra.Command, []string) error { return nil }, + } + root.AddCommand(stub) + + // User-layer pruning denies the same path. + denied := map[string]cmdpolicy.Denial{ + "victim": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + Reason: "denied by user yaml", + ReasonCode: "command_denylisted", + }, + } + cmdpolicy.Apply(root, denied) + + if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode { + t.Errorf("strict-mode layer overwritten by pruning: got %q want %q", + got, cmdpolicy.LayerStrictMode) + } + if got := stub.Annotations[cmdpolicy.AnnotationDenialSource]; got != "strict-mode" { + t.Errorf("strict-mode source overwritten: got %q", got) + } +} + +// Regression for codex H13 / C6: a denied command that carries +// flag-like positional args (because DisableFlagParsing=true makes +// every `--doc xxx` look positional) MUST surface the pruning +// envelope, not a cobra usage error. Pre-fix, the original command's +// Args validator (e.g. cobra.NoArgs from shortcut registration) would +// fire BEFORE PersistentPreRunE / RunE and produce +// "Error: positional arguments are not supported". +// +// Fix: installDenyStub sets Args=ArbitraryArgs so cobra's validate +// step always passes, letting dispatch reach the wrapped RunE. +func TestApply_DenyStubBypassesArgsValidator(t *testing.T) { + root := &cobra.Command{Use: "root"} + leaf := &cobra.Command{ + Use: "+update", + Args: cobra.NoArgs, // shortcut style: refuse all positional args + RunE: func(*cobra.Command, []string) error { return nil }, + } + root.AddCommand(leaf) + + denied := map[string]cmdpolicy.Denial{ + "+update": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + ReasonCode: "command_denylisted", + Reason: "denied by user yaml", + }, + } + cmdpolicy.Apply(root, denied) + + if leaf.Args == nil { + t.Fatal("denied command must have non-nil Args validator after Apply") + } + // ArbitraryArgs returns nil for every input -> Args validation no-ops. + if err := leaf.Args(leaf, []string{"--doc", "xxx", "--mode", "append"}); err != nil { + t.Errorf("denied command Args validator should accept any input, got %v", err) + } +} + +// Regression for codex C11 / C13: a denied command whose PARENT +// declares a PersistentPreRunE (e.g. cmd/auth/auth.go's +// external_provider check) MUST surface the pruning envelope, not +// the parent's error. Cobra's "first PersistentPreRunE walking up +// from leaf wins" semantics will pick the parent's PersistentPreRunE +// unless the denied leaf carries its own. +// +// Fix: installDenyStub installs a no-op PersistentPreRunE on the leaf +// so cobra stops there and proceeds to the wrapped RunE (which holds +// the real pruning envelope). +func TestApply_DenyStubBypassesParentPersistentPreRunE(t *testing.T) { + root := &cobra.Command{Use: "root"} + parent := &cobra.Command{ + Use: "auth", + PersistentPreRunE: func(*cobra.Command, []string) error { + return errors.New("parent PersistentPreRunE fired (would mask pruning)") + }, + } + root.AddCommand(parent) + leaf := &cobra.Command{ + Use: "login", + RunE: func(*cobra.Command, []string) error { return nil }, + } + parent.AddCommand(leaf) + + denied := map[string]cmdpolicy.Denial{ + "auth/login": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + ReasonCode: "identity_mismatch", + Reason: "denied", + }, + } + cmdpolicy.Apply(root, denied) + + if leaf.PersistentPreRunE == nil { + t.Fatal("denied command must have leaf-level PersistentPreRunE") + } + // Our PersistentPreRunE must NOT propagate the parent's error. + if err := leaf.PersistentPreRunE(leaf, nil); err != nil { + t.Errorf("denied command leaf PersistentPreRunE should be no-op, got %v", err) + } +} + +// Sanity: a normal command (no prior annotation) still gets the +// pruning denial annotations after Apply. +func TestApply_NonStrictCommandStillGetsPruningAnnotation(t *testing.T) { + root := &cobra.Command{Use: "root"} + leaf := &cobra.Command{ + Use: "normal", + RunE: func(*cobra.Command, []string) error { return nil }, + } + root.AddCommand(leaf) + + denied := map[string]cmdpolicy.Denial{ + "normal": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + Reason: "denied", + ReasonCode: "command_denylisted", + }, + } + cmdpolicy.Apply(root, denied) + + if got := leaf.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerPolicy { + t.Errorf("expected pruning layer annotation, got %q", got) + } +} diff --git a/internal/cmdpolicy/suggest.go b/internal/cmdpolicy/suggest.go new file mode 100644 index 000000000..2f7362e31 --- /dev/null +++ b/internal/cmdpolicy/suggest.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "github.com/larksuite/cli/extension/platform" +) + +// suggestRisk returns the closest valid Risk literal by edit distance +// for risk_invalid diagnostics; input is never silently substituted. +// Case-insensitive ("WRITE" → "write"); empty in, empty out (the +// absent-annotation case goes to risk_not_annotated, not here). +func suggestRisk(bad string) string { + if bad == "" { + return "" + } + lowered := toLower(bad) + candidates := []platform.Risk{ + platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite, + } + best := string(candidates[0]) + bestDist := levenshtein(lowered, best) + for _, c := range candidates[1:] { + if d := levenshtein(lowered, string(c)); d < bestDist { + bestDist, best = d, string(c) + } + } + return best +} + +// toLower is an ASCII-only lowercase. Risk taxonomy values are +// ASCII; pulling in unicode here would be overkill. +func toLower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +// levenshtein computes the classic edit distance between two strings. +// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set +// makes raw performance irrelevant — clarity beats trickiness here. +func levenshtein(a, b string) int { + if len(a) == 0 { + return len(b) + } + if len(b) == 0 { + return len(a) + } + prev := make([]int, len(b)+1) + curr := make([]int, len(b)+1) + for j := 0; j <= len(b); j++ { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + curr[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min3( + prev[j]+1, // deletion + curr[j-1]+1, // insertion + prev[j-1]+cost, // substitution + ) + } + prev, curr = curr, prev + } + return prev[len(b)] +} + +func min3(a, b, c int) int { + m := a + if b < m { + m = b + } + if c < m { + m = c + } + return m +} diff --git a/internal/cmdpolicy/suggest_test.go b/internal/cmdpolicy/suggest_test.go new file mode 100644 index 000000000..da91495a2 --- /dev/null +++ b/internal/cmdpolicy/suggest_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import "testing" + +// suggest is unexported, so the test lives in the same package. + +func TestSuggestRisk(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"wrtie", "write"}, + {"WRITE", "write"}, + {"reed", "read"}, + {"rad", "read"}, + {"high-rik-write", "high-risk-write"}, + // "highrisk" is genuinely ambiguous between "write" and + // "high-risk-write" — not testing it. + {"", ""}, // empty input has no meaningful suggestion; the engine + // routes the absent case to risk_not_annotated, not risk_invalid. + } + for _, c := range cases { + got := suggestRisk(c.input) + if got != c.want { + t.Errorf("suggestRisk(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +func TestLevenshtein(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"", "abc", 3}, + {"abc", "", 3}, + {"abc", "abc", 0}, + {"wrtie", "write", 2}, + {"kitten", "sitting", 3}, + } + for _, c := range cases { + got := levenshtein(c.a, c.b) + if got != c.want { + t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} diff --git a/internal/cmdpolicy/validate.go b/internal/cmdpolicy/validate.go new file mode 100644 index 000000000..21bb168fb --- /dev/null +++ b/internal/cmdpolicy/validate.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "fmt" + + "github.com/bmatcuk/doublestar/v4" + + "github.com/larksuite/cli/extension/platform" +) + +// ValidateRule is the single Rule-validation entry point. It runs from +// every source: yaml file load, Plugin.Restrict (once the Hook surface +// lands), and the policy CLI's validate subcommand. Catching invalid +// rules HERE rather than during evaluation prevents silent fail-open +// scenarios: +// +// - bad MaxRisk string ("readd") would skip the risk check entirely +// - malformed doublestar pattern ("docs/[abc") never matches, so a +// plugin that meant to allow "docs/*" silently allows nothing, +// and a deny list with the same typo silently denies nothing +// +// A typo in either field by a plugin author or admin must abort the load +// rather than continue with a degraded rule (hard-constraint #6 / #11 +// safety contract). +// +// A nil rule is a no-op (treated as "no restriction" everywhere -- not an +// error). +func ValidateRule(r *platform.Rule) error { + if r == nil { + return nil + } + + if r.MaxRisk != "" { + if !r.MaxRisk.IsValid() { + return fmt.Errorf("invalid max_risk %q: must be one of read|write|high-risk-write", r.MaxRisk) + } + } + + for _, id := range r.Identities { + if !id.IsValid() { + return fmt.Errorf("invalid identities entry %q: must be 'user' or 'bot'", id) + } + } + + for _, g := range r.Allow { + if err := validateGlob(g); err != nil { + return fmt.Errorf("invalid allow glob %q: %w", g, err) + } + } + for _, g := range r.Deny { + if err := validateGlob(g); err != nil { + return fmt.Errorf("invalid deny glob %q: %w", g, err) + } + } + return nil +} + +// validateGlob rejects malformed doublestar patterns. doublestar.Match +// returns an error for unbalanced brackets / bad escape sequences; that +// error path is the canonical signal for "this pattern is not valid". +// +// We probe with an empty string -- the goal is to exercise the parser, +// not to compute a match. +func validateGlob(g string) error { + if g == "" { + return fmt.Errorf("empty pattern") + } + if _, err := doublestar.Match(g, ""); err != nil { + return err + } + return nil +} diff --git a/internal/cmdpolicy/validate_test.go b/internal/cmdpolicy/validate_test.go new file mode 100644 index 000000000..3961f12a3 --- /dev/null +++ b/internal/cmdpolicy/validate_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" +) + +// nil rule is "no restriction" everywhere -- validation must agree. +func TestValidateRule_nilIsOk(t *testing.T) { + if err := cmdpolicy.ValidateRule(nil); err != nil { + t.Fatalf("nil rule should validate, got %v", err) + } +} + +func TestValidateRule_validRule(t *testing.T) { + r := &platform.Rule{ + Allow: []string{"docs/**", "contact/+search-*"}, + Deny: []string{"docs/+delete-doc"}, + MaxRisk: "write", + Identities: []platform.Identity{"user", "bot"}, + } + if err := cmdpolicy.ValidateRule(r); err != nil { + t.Fatalf("valid rule rejected: %v", err) + } +} + +// A typo in MaxRisk must abort the load; otherwise the engine would skip +// the risk check entirely and let high-risk-write commands pass under +// what the operator thought was a "read" cap. +func TestValidateRule_badMaxRisk(t *testing.T) { + cases := []string{"readd", "Read", "high_risk_write", "anything"} + for _, bad := range cases { + r := &platform.Rule{MaxRisk: platform.Risk(bad)} + err := cmdpolicy.ValidateRule(r) + if err == nil { + t.Errorf("ValidateRule should reject MaxRisk=%q", bad) + continue + } + if !strings.Contains(err.Error(), "max_risk") { + t.Errorf("error should mention max_risk for MaxRisk=%q, got %v", bad, err) + } + } +} + +// Identities must come from the closed taxonomy {"user","bot"}. A typo +// like "users" would silently lock out everyone (no command intersects +// the typo), so it must abort. +func TestValidateRule_badIdentity(t *testing.T) { + r := &platform.Rule{Identities: []platform.Identity{"user", "admin"}} + err := cmdpolicy.ValidateRule(r) + if err == nil { + t.Fatalf("ValidateRule should reject identity 'admin'") + } + if !strings.Contains(err.Error(), "identities") { + t.Fatalf("error should mention identities, got %v", err) + } +} + +// Malformed doublestar globs are silent fail-open if not caught here +// (doublestar.Match returns an error which matchesAny() ignores). +func TestValidateRule_malformedGlob(t *testing.T) { + cases := []struct { + name string + rule *platform.Rule + }{ + {"bad allow", &platform.Rule{Allow: []string{"docs/[abc"}}}, + {"bad deny", &platform.Rule{Deny: []string{"docs/[abc"}}}, + {"empty allow entry", &platform.Rule{Allow: []string{"", "docs/**"}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := cmdpolicy.ValidateRule(c.rule) + if err == nil { + t.Fatalf("ValidateRule should reject %+v", c.rule) + } + }) + } +} + +// Empty MaxRisk and Empty Identities slices are both "no restriction" -- +// not an error. +func TestValidateRule_emptyFieldsAreOk(t *testing.T) { + r := &platform.Rule{ + Allow: []string{"docs/**"}, + MaxRisk: "", + Identities: nil, + } + if err := cmdpolicy.ValidateRule(r); err != nil { + t.Fatalf("empty optional fields should validate, got %v", err) + } +} diff --git a/internal/cmdpolicy/yaml/reader.go b/internal/cmdpolicy/yaml/reader.go new file mode 100644 index 000000000..41e85a4c9 --- /dev/null +++ b/internal/cmdpolicy/yaml/reader.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package yaml + +import "io" + +// bytesReader avoids pulling in bytes.NewReader at the call site -- yaml.v3 +// only needs an io.Reader. Plain wrapper, no allocation surprises. +type byteReader struct { + data []byte + pos int +} + +func bytesReader(data []byte) io.Reader { return &byteReader{data: data} } + +func (b *byteReader) Read(p []byte) (int, error) { + if b.pos >= len(b.data) { + return 0, io.EOF + } + n := copy(p, b.data[b.pos:]) + b.pos += n + return n, nil +} diff --git a/internal/cmdpolicy/yaml/schema.go b/internal/cmdpolicy/yaml/schema.go new file mode 100644 index 000000000..718d2a8bd --- /dev/null +++ b/internal/cmdpolicy/yaml/schema.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package yaml parses a Rule from yaml bytes. It is kept separate from the +// public extension/platform package so that platform stays free of yaml +// library dependencies -- plugins constructing a Rule in Go code never +// import yaml, only the file loader does. +// +// This package does **structural** parsing only (yaml syntax + unknown-field +// rejection). Semantic validation (valid MaxRisk enum, valid identity +// values, valid doublestar glob syntax) is centralised in +// internal/cmdpolicy.ValidateRule so a single contract is enforced regardless +// of whether the Rule came from yaml or from Plugin.Restrict. +package yaml + +import ( + "errors" + "fmt" + "io" + + gopkgyaml "gopkg.in/yaml.v3" + + "github.com/larksuite/cli/extension/platform" +) + +// schema is the internal yaml-tagged shape. Mirrors platform.Rule but lives +// here so the public Rule has no yaml tag baggage. +type schema struct { + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Allow []string `yaml:"allow,omitempty"` + Deny []string `yaml:"deny,omitempty"` + MaxRisk string `yaml:"max_risk,omitempty"` + Identities []string `yaml:"identities,omitempty"` + AllowUnannotated bool `yaml:"allow_unannotated,omitempty"` +} + +// Parse decodes yaml bytes into a *platform.Rule. Unknown fields are +// rejected so an old binary cannot silently ignore new schema additions +// (forward-compat safeguard). +// +// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is +// the caller's responsibility -- run the result through +// internal/cmdpolicy.ValidateRule before handing it to the engine. +func Parse(data []byte) (*platform.Rule, error) { + var s schema + dec := gopkgyaml.NewDecoder(bytesReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("parse policy yaml: %w", err) + } + + // Reject multi-document input: yaml.v3 only decodes one document + // per call, so a stray "---" followed by another document would + // silently drop the trailing rule. + var extra schema + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed") + } + return nil, fmt.Errorf("parse policy yaml: %w", err) + } + + idents := make([]platform.Identity, len(s.Identities)) + for i, id := range s.Identities { + idents[i] = platform.Identity(id) + } + return &platform.Rule{ + Name: s.Name, + Description: s.Description, + Allow: s.Allow, + Deny: s.Deny, + MaxRisk: platform.Risk(s.MaxRisk), + Identities: idents, + AllowUnannotated: s.AllowUnannotated, + }, nil +} diff --git a/internal/cmdpolicy/yaml/schema_test.go b/internal/cmdpolicy/yaml/schema_test.go new file mode 100644 index 000000000..912c8b2a5 --- /dev/null +++ b/internal/cmdpolicy/yaml/schema_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package yaml_test + +import ( + "reflect" + "testing" + + "github.com/larksuite/cli/extension/platform" + pyaml "github.com/larksuite/cli/internal/cmdpolicy/yaml" +) + +func TestParse_validRule(t *testing.T) { + data := []byte(` +name: agent-docs-readonly +description: only-read docs +allow: + - docs/** + - contact/** +deny: + - docs/+update +max_risk: read +identities: + - user +`) + rule, err := pyaml.Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + want := &platform.Rule{ + Name: "agent-docs-readonly", + Description: "only-read docs", + Allow: []string{"docs/**", "contact/**"}, + Deny: []string{"docs/+update"}, + MaxRisk: "read", + Identities: []platform.Identity{"user"}, + } + if !reflect.DeepEqual(rule, want) { + t.Fatalf("rule = %+v, want %+v", rule, want) + } +} + +// allow_unannotated is documented in the README / author guide as the +// gradual-adoption opt-in. The yaml schema must carry it through to +// platform.Rule, otherwise a user following the docs would either hit +// "unknown field" (under KnownFields strict mode) or silently lose the +// opt-in and end up with a safer-but-broken policy. +func TestParse_allowUnannotatedPassesThrough(t *testing.T) { + data := []byte(` +name: agent-readonly +max_risk: read +allow_unannotated: true +`) + rule, err := pyaml.Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if !rule.AllowUnannotated { + t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)") + } + if rule.MaxRisk != "read" || rule.Name != "agent-readonly" { + t.Errorf("other fields lost: %+v", rule) + } +} + +// Default is false when the key is absent: pin the fail-closed default so +// future schema edits cannot accidentally flip it. +func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) { + data := []byte(` +name: x +max_risk: read +`) + rule, err := pyaml.Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if rule.AllowUnannotated { + t.Fatalf("AllowUnannotated must default to false when key is absent") + } +} + +// Unknown fields must be rejected so the old binary cannot silently ignore +// new schema additions (forward-compat safeguard). +func TestParse_rejectsUnknownFields(t *testing.T) { + data := []byte(` +name: x +mystery_field: oh no +`) + if _, err := pyaml.Parse(data); err == nil { + t.Fatalf("Parse should reject unknown yaml field 'mystery_field'") + } +} + +// Semantic validation lives in cmdpolicy.ValidateRule. Parse only checks +// structural yaml; an invalid max_risk passes through (validation happens +// downstream). +func TestParse_doesNotValidateSemantics(t *testing.T) { + rule, err := pyaml.Parse([]byte("max_risk: nuclear\n")) + if err != nil { + t.Fatalf("structural parse should succeed, got %v", err) + } + if rule.MaxRisk != "nuclear" { + t.Fatalf("MaxRisk = %q, want passed through as-is", rule.MaxRisk) + } +} + +// An entirely empty file is rejected: the resolver should fall back to +// "no rule" by skipping the file in the first place, not by feeding empty +// bytes through Parse. +func TestParse_emptyIsError(t *testing.T) { + if _, err := pyaml.Parse([]byte{}); err == nil { + t.Fatalf("Parse should reject empty input; the resolver handles 'no file' separately") + } +} + +// A stray "---" separator followed by another document would silently +// drop the trailing rule if yaml.v3 stopped after the first Decode. +// Parse must reject multi-document input so the operator can't typo a +// separator and end up with an unintentionally empty policy. +func TestParse_rejectsMultipleDocuments(t *testing.T) { + data := []byte(`name: first +max_risk: read +--- +name: second +max_risk: write +`) + if _, err := pyaml.Parse(data); err == nil { + t.Fatalf("Parse should reject multi-document YAML input") + } +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 1ccfee440..5eff1931f 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -161,7 +161,7 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode { func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error { mode := f.ResolveStrictMode(ctx) if mode.IsActive() && !mode.AllowsIdentity(as) { - return output.ErrWithHint(output.ExitValidation, "strict_mode", + return output.ErrWithHint(output.ExitValidation, "command_denied", fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()), "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } diff --git a/internal/hook/doc.go b/internal/hook/doc.go new file mode 100644 index 000000000..6993cb1bb --- /dev/null +++ b/internal/hook/doc.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package hook is the internal Hook dispatch implementation. It owns: +// +// - Registry the in-memory data store mapping (Stage|Event) -> +// registered hooks for fast dispatch +// - Install(root, …) the entry point that wraps every command's RunE +// so Before/After Observers and Wrap chains fire +// around the command's business logic, including +// the denial guard that physically isolates +// pruned commands from Wrap. +// - Emit(event, …) the lifecycle event firing helper used by the +// Bootstrap pipeline. +// +// Plugins NEVER import this package -- they only ever see +// extension/platform. The Registrar contract is implemented inside +// internal/platform, which delegates to this Registry after validating +// the plugin's calls (staging + atomic commit). +package hook diff --git a/internal/hook/emit.go b/internal/hook/emit.go new file mode 100644 index 000000000..c7cf6ed26 --- /dev/null +++ b/internal/hook/emit.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import ( + "context" + "fmt" + "time" + + "github.com/larksuite/cli/extension/platform" +) + +// shutdownDeadline is the hard upper bound on how long Shutdown +// handlers in total may run. Past this, the framework returns control +// to the caller regardless of unfinished handlers. 2s matches the +// design-doc constraint. +const shutdownDeadline = 2 * time.Second + +// LifecycleError is the typed failure returned by Emit for non-Shutdown +// events when a LifecycleHandler returns an error or panics. Callers can +// errors.As to extract HookName, Event, and the Panic discriminator +// (panic vs returned error) so the envelope writer can produce +// distinct reason_code values: +// +// - Panic == false -> reason_code = "lifecycle_failed" +// - Panic == true -> reason_code = "lifecycle_panic" +// +// Shutdown handler failures are logged inside emitShutdown and never +// returned through this type (Shutdown is non-recoverable; the contract +// is "best effort, never block exit"). +type LifecycleError struct { + Event platform.LifecycleEvent + HookName string + Panic bool + Cause error +} + +func (e *LifecycleError) Error() string { + kind := "failed" + if e.Panic { + kind = "panic" + } + return fmt.Sprintf("lifecycle hook %q %s: %v", e.HookName, kind, e.Cause) +} + +func (e *LifecycleError) Unwrap() error { return e.Cause } + +// Emit fires every LifecycleHandler registered for event in +// registration order. lastErr is propagated to handlers via +// LifecycleContext.Err (typical use: Shutdown handlers see the error +// the command exited with). +// +// Behaviour by event: +// +// - Startup: any handler returning a non-nil error aborts the +// bootstrap (caller decides whether to fail-closed). The first +// such error is returned as *LifecycleError. +// +// - Shutdown: handler errors are logged but do not affect the +// returned error; the framework also caps the total time at +// shutdownDeadline. +func Emit(ctx context.Context, reg *Registry, event platform.LifecycleEvent, lastErr error) error { + if reg == nil { + return nil + } + handlers := reg.LifecycleHandlers(event) + if len(handlers) == 0 { + return nil + } + lc := &platform.LifecycleContext{Event: event, Err: lastErr} + + if event == platform.Shutdown { + return emitShutdown(ctx, handlers, lc) + } + for _, h := range handlers { + if err := callLifecycleSafe(ctx, h, lc); err != nil { + return err + } + } + return nil +} + +// emitShutdown enforces the 2-second total deadline. Handlers receive +// a derived context with the remaining budget; once the budget is +// exhausted, the remaining handlers are skipped (with a stderr +// warning) and Emit returns. +func emitShutdown(parent context.Context, handlers []LifecycleEntry, lc *platform.LifecycleContext) error { + ctx, cancel := context.WithTimeout(parent, shutdownDeadline) + defer cancel() + deadline := time.Now().Add(shutdownDeadline) + + for _, h := range handlers { + if time.Now().After(deadline) { + fmt.Fprintf(stderr(), "warning: shutdown deadline exceeded; skipping hook %q\n", h.Name) + continue + } + if err := callLifecycleSafe(ctx, h, lc); err != nil { + // Shutdown errors are logged, not propagated -- exit is + // non-recoverable anyway. + fmt.Fprintf(stderr(), "warning: shutdown hook %q: %v\n", h.Name, err) + } + } + return nil +} + +// callLifecycleSafe invokes a LifecycleHandler with panic recovery. +// Returns *LifecycleError with Panic=true on recovered panic, Panic=false +// on a regular returned error. nil if the handler succeeded. +func callLifecycleSafe(ctx context.Context, h LifecycleEntry, lc *platform.LifecycleContext) (err error) { + defer func() { + if r := recover(); r != nil { + err = &LifecycleError{ + Event: lc.Event, + HookName: h.Name, + Panic: true, + Cause: fmt.Errorf("%v", r), + } + } + }() + if e := h.Fn(ctx, lc); e != nil { + return &LifecycleError{ + Event: lc.Event, + HookName: h.Name, + Panic: false, + Cause: e, + } + } + return nil +} diff --git a/internal/hook/emit_test.go b/internal/hook/emit_test.go new file mode 100644 index 000000000..df6b0af61 --- /dev/null +++ b/internal/hook/emit_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import ( + "context" + "errors" + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +// A Startup handler returning a regular error must surface as a typed +// *LifecycleError with Panic=false so the cmd-layer guard can pick +// reason_code=lifecycle_failed. +func TestEmit_StartupHandlerError_TypedError(t *testing.T) { + reg := NewRegistry() + want := errors.New("backend down") + reg.AddLifecycle(LifecycleEntry{ + Event: platform.Startup, + Name: "p.boot", + Fn: func(context.Context, *platform.LifecycleContext) error { return want }, + }) + + got := Emit(context.Background(), reg, platform.Startup, nil) + if got == nil { + t.Fatal("expected error from Emit, got nil") + } + var le *LifecycleError + if !errors.As(got, &le) { + t.Fatalf("expected *LifecycleError, got %T %v", got, got) + } + if le.Panic { + t.Errorf("Panic = true, want false (returned error)") + } + if le.HookName != "p.boot" { + t.Errorf("HookName = %q, want p.boot", le.HookName) + } + if !errors.Is(got, want) { + t.Errorf("unwrap should reach original error") + } +} + +// A Startup handler that panics must be recovered and surface as a +// typed *LifecycleError with Panic=true so the cmd-layer guard can +// pick reason_code=lifecycle_panic. +func TestEmit_StartupHandlerPanic_TypedError(t *testing.T) { + reg := NewRegistry() + reg.AddLifecycle(LifecycleEntry{ + Event: platform.Startup, + Name: "p.boot", + Fn: func(context.Context, *platform.LifecycleContext) error { panic("boom") }, + }) + + got := Emit(context.Background(), reg, platform.Startup, nil) + if got == nil { + t.Fatal("expected error from Emit, got nil") + } + var le *LifecycleError + if !errors.As(got, &le) { + t.Fatalf("expected *LifecycleError, got %T %v", got, got) + } + if !le.Panic { + t.Errorf("Panic = false, want true (recovered panic)") + } + if le.HookName != "p.boot" { + t.Errorf("HookName = %q, want p.boot", le.HookName) + } +} + +// A Startup handler that succeeds returns nil; subsequent handlers run. +func TestEmit_StartupAllHandlersRun(t *testing.T) { + reg := NewRegistry() + var calls []string + reg.AddLifecycle(LifecycleEntry{ + Event: platform.Startup, Name: "a", + Fn: func(context.Context, *platform.LifecycleContext) error { + calls = append(calls, "a") + return nil + }, + }) + reg.AddLifecycle(LifecycleEntry{ + Event: platform.Startup, Name: "b", + Fn: func(context.Context, *platform.LifecycleContext) error { + calls = append(calls, "b") + return nil + }, + }) + if err := Emit(context.Background(), reg, platform.Startup, nil); err != nil { + t.Fatalf("Emit: %v", err) + } + if len(calls) != 2 || calls[0] != "a" || calls[1] != "b" { + t.Errorf("handlers fired in unexpected order: %v", calls) + } +} + +// Shutdown handler errors are logged, not propagated; Emit returns nil. +func TestEmit_ShutdownErrorsSwallowed(t *testing.T) { + reg := NewRegistry() + reg.AddLifecycle(LifecycleEntry{ + Event: platform.Shutdown, Name: "flush", + Fn: func(context.Context, *platform.LifecycleContext) error { + return errors.New("flush failed") + }, + }) + if err := Emit(context.Background(), reg, platform.Shutdown, nil); err != nil { + t.Errorf("Shutdown errors must NOT propagate, got: %v", err) + } +} diff --git a/internal/hook/install.go b/internal/hook/install.go new file mode 100644 index 000000000..53fbe6f78 --- /dev/null +++ b/internal/hook/install.go @@ -0,0 +1,358 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/output" +) + +// Install wraps every runnable command's RunE so the hook chain fires +// around it. The wrapper is: +// +// Before observers (always run, panic-safe) +// denial guard: +// if cmd is denied -> denyStub returns its CommandDeniedError +// else -> compose(matched Wrappers)(originalRunE) runs +// After observers (always run, panic-safe, sees inv.Err) +// +// Critical invariants enforced here (constraint #2): +// +// - **Denied commands NEVER reach the Wrap chain.** The guard runs +// denyStub directly so no plugin Wrapper can suppress or rewrite +// the denial. Observers still fire (audit must see the attempted +// call), but Wrap is physically out of the path. +// +// - **After observers always fire**, even when RunE returned an +// error. Wrap short-circuits via AbortError get converted to +// *output.ExitError so cmd/root.go emits the right envelope. +// +// - **Denial layer / source are populated from cobra annotations +// before any hook fires.** populateInvocationDenial reads the +// annotations attached by cmdpolicy.Apply and strictModeStubFrom, +// avoiding an import cycle between hook and cmdpolicy. +// +// Install must be called once during the Bootstrap pipeline after +// policy pruning has finished. Calling it twice on the same tree is a +// bug (each command's RunE would be wrapped multiple times). +func Install(root *cobra.Command, reg *Registry, snapshot CommandViewSource) { + if root == nil || reg == nil { + return + } + walkTree(root, func(c *cobra.Command) { + if !c.Runnable() { + return + } + if !c.HasParent() { + return // do not wrap the binary root itself + } + wrapRunE(c, reg, snapshot) + }) +} + +// CommandViewSource resolves a *cobra.Command into a CommandView. The +// default implementation returns a live view over the cobra node; +// strict-mode's replacement stubs (cmd/prune.go) carry the original +// command's annotations forward so the view keeps reporting accurate +// Risk / Identities / Domain after replacement. +type CommandViewSource interface { + View(cmd *cobra.Command) platform.CommandView +} + +// wrapRunE replaces cmd.RunE with a hook-aware wrapper. The original +// RunE is captured by closure so the Wrapper chain can still call it +// as the innermost handler. +// +// The wrapper preserves the Run vs RunE distinction: cmd.Run is +// cleared because RunE wins when both are set and leaving a stale Run +// around is a hazard for future maintainers. +func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) { + originalRunE := cmd.RunE + originalRun := cmd.Run + cmd.Run = nil + + cmd.RunE = func(c *cobra.Command, args []string) error { + view := snapshot.View(c) + inv := newInvocation(view, args) + + // Detect denial: a denied command's original RunE was already + // replaced by cmdpolicy.Apply with a denyStub that returns + // *output.ExitError wrapping *platform.CommandDeniedError. We + // invoke originalRunE once with a probe-only context (no args + // matter because DisableFlagParsing is set on denied commands) + // to extract its CommandDeniedError, but for V1 we use a + // simpler shortcut: cmdpolicy.Apply itself marks the command + // via cobra annotation; install reads the annotation directly. + populateInvocationDenial(inv, c) + + ctx := c.Context() + if ctx == nil { + ctx = context.Background() + } + + // === Before observers (panic-safe, always run) === + for _, obs := range reg.MatchingObservers(view, platform.Before) { + runObserverSafe(ctx, obs, inv) + } + + // === Denial guard === + // If denied, run the originalRunE directly (it is the denyStub + // installed by cmdpolicy.Apply). The Wrap chain is bypassed. + var err error + if inv.DeniedByPolicy() { + err = invokeOriginal(ctx, c, args, originalRunE, originalRun) + } else { + // Compose matching Wrappers around the originalRunE. Each + // Wrapper is wrapped with a thin namespacing shim so any + // *AbortError returned has its HookName replaced with the + // framework-namespaced WrapperEntry.Name -- a plugin + // cannot impersonate another plugin's hook even by + // accident. + matched := reg.MatchingWrappers(view) + wrappers := make([]platform.Wrapper, 0, len(matched)) + for _, w := range matched { + // Each plugin Wrapper is wrapped twice: once by the + // namespacing shim (AbortError attribution) and once + // by the panic shim (so a plugin panic becomes a + // structured hook envelope instead of crashing the + // process). + wrappers = append(wrappers, recoverWrap(w.Name, namespacedWrap(w.Name, w.Fn))) + } + composed := ComposeWrappers(wrappers) + // Pass the wrapRunE-local args, not i.Args(): the original + // RunE must see what cobra parsed, not what a hook may have + // observed via the read-only interface. + finalHandler := composed(func(c2 context.Context, _ platform.Invocation) error { + return invokeOriginal(c2, c, args, originalRunE, originalRun) + }) + err = finalHandler(ctx, inv) + } + + // Convert AbortError -> *output.ExitError so the envelope writer + // renders the structured "hook" type. + err = wrapAbortError(err) + + inv.setErr(err) + + // === After observers (panic-safe, always run, including + // when err != nil) === + for _, obs := range reg.MatchingObservers(view, platform.After) { + runObserverSafe(ctx, obs, inv) + } + + return err + } +} + +// invokeOriginal runs whatever the original command logic was. If +// originalRunE is non-nil (the common case), use it; otherwise fall +// back to the Run variant. Commands without either are a programming +// error caught at registration time (cmd.Runnable() returns false). +// +// The wrapper-propagated ctx is set on cmd via SetContext *before* the +// inner RunE/Run is invoked, so any context values injected by an +// upstream Wrapper (auth tokens, request-scoped IDs, trace spans, +// cancellation deadlines) reach the original handler. Without this +// hand-off the inner handler would observe c.Context() — the +// pre-wrapper context — and silently lose every value the Wrap chain +// added. +// +// We restore the previous context on return so a single command's +// SetContext mutation cannot leak to sibling dispatches that share the +// same *cobra.Command pointer (cobra reuses the tree across calls in +// long-running embedders). +func invokeOriginal(ctx context.Context, c *cobra.Command, args []string, runE func(*cobra.Command, []string) error, run func(*cobra.Command, []string)) error { + prev := c.Context() + c.SetContext(ctx) + defer c.SetContext(prev) + + if runE != nil { + return runE(c, args) + } + if run != nil { + run(c, args) + return nil + } + return nil +} + +// runObserverSafe invokes an Observer with panic recovery. Observers +// must not break the main flow; their job is side-effect-only and a +// broken plugin should not cascade into a failed CLI run. +func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invocation) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(stderr(), "warning: hook %q panicked: %v\n", obs.Name, r) + } + }() + obs.Fn(ctx, inv) +} + +// wrapAbortError converts *platform.AbortError into the equivalent +// *output.ExitError so cmd/root.go's envelope writer emits the right +// JSON structure (type="hook"). Non-AbortError values pass through +// unchanged. +func wrapAbortError(err error) error { + if err == nil { + return nil + } + var ab *platform.AbortError + if !errors.As(err, &ab) { + return err + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "hook", + Message: ab.Error(), + Detail: map[string]any{ + "hook_name": ab.HookName, + "reason": ab.Reason, + "reason_code": "aborted", + "detail": ab.Detail, + }, + }, + Err: ab, + } +} + +// recoverWrap wraps a Wrapper so any panic anywhere in the plugin's +// implementation -- including the wrapper FACTORY call (the +// `func(next Handler) Handler` step) and the inner Handler call -- is +// recovered and surfaced as a structured *output.ExitError with +// type="hook" and reason_code="panic". Without this guard, a panicking +// plugin would crash the entire CLI process and break the structured- +// error contract (downstream automation cannot parse a stack trace). +// +// The recovered panic keeps the fully-qualified hook name (the same +// namespacing as namespacedWrap below uses) so on-call can pinpoint +// the offending plugin without grepping logs. +// +// **Why the factory call is inside the deferred recover**: a plugin +// can write something like +// +// func(next Handler) Handler { +// state := mustInit() // panics on bad config +// return func(...) error { ... use state ... } +// } +// +// If `mustInit` panics, the panic happens during composition +// (ComposeWrappers -> ws[i](next)) which runs at invocation time inside +// wrapRunE. Without recovering this branch, the whole CLI crashes. +// We pay a tiny per-invocation cost (one factory call per command +// dispatch) in exchange for total panic isolation. +// +// **Factory-local state lifetime contract**: any value the plugin's +// outer factory captures (`state` in the example above) is now created +// PER INVOCATION of the wrapped command -- it is NOT a one-shot init +// the way Plugin.Install is. Plugins that need long-lived state (a +// connection pool, an LRU cache, a metrics counter) MUST hold it on +// the Plugin struct or in a package-level variable; relying on +// closure-local memoisation inside the wrapper factory will silently +// reset on every command dispatch. +func recoverWrap(fullName string, w platform.Wrapper) platform.Wrapper { + return func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) (returned error) { + defer func() { + if r := recover(); r != nil { + returned = &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "hook", + Message: fmt.Sprintf("hook %q panicked: %v", fullName, r), + Detail: map[string]any{ + "hook_name": fullName, + "reason_code": "panic", + "reason": fmt.Sprintf("%v", r), + }, + }, + Err: fmt.Errorf("hook %q panic: %v", fullName, r), + } + } + }() + // Construct AFTER the recover is armed so a panicking + // factory becomes a hook envelope instead of a process + // crash. + inner := w(next) + return inner(ctx, inv) + } + } +} + +// namespacedWrap wraps a plugin's Wrapper so any *platform.AbortError it +// returns is replaced with a fresh copy whose HookName is the +// framework-namespaced name (e.g. "policy-plugin.policy"). Plugin +// authors do not need to know their own plugin name; the framework +// attribution is authoritative. +// +// **Why a copy, not mutation**: an AbortError value may be shared +// across concurrent command invocations (e.g. a plugin's package-level +// sentinel). Mutating it would race; copy keeps each invocation's +// attribution isolated. +// +// **Why only top-level AbortError, not wrapped**: a wrapped AbortError +// in a chain via fmt.Errorf("...: %w", ab) would require rebuilding +// the entire chain to substitute the value. The simpler contract -- +// "plugin returns AbortError directly to short-circuit" -- is what we +// document, so we only namespace the top-level case. Wrapped +// AbortErrors keep whatever HookName the plugin set; that is still +// surfaced unchanged by the envelope writer. +func namespacedWrap(fullName string, w platform.Wrapper) platform.Wrapper { + return func(next platform.Handler) platform.Handler { + inner := w(next) + return func(ctx context.Context, inv platform.Invocation) error { + err := inner(ctx, inv) + if err == nil { + return nil + } + if ab, ok := err.(*platform.AbortError); ok { + copied := *ab + copied.HookName = fullName + return &copied + } + return err + } + } +} + +// stderr returns the stderr writer the wrapper uses for safe warnings. +// Indirected through a func so tests can substitute it. +var stderr = func() interface{ Write(p []byte) (int, error) } { + // Avoid pulling os just for stderr access -- the real impl lives + // in install_default.go (see file). The function is overridable + // to keep test isolation tight. + return defaultStderr +} + +// populateInvocationDenial reads the cobra annotation set by +// cmdpolicy.Apply and propagates it onto the framework-internal +// invocation. +// +// V1 contract: a denial is signalled by the cobra annotation +// "lark:policy_denied_layer" being set on the command. The layer +// value is the enforcement layer ("policy" / "strict_mode") that +// gets emitted as detail.layer in the envelope; the source follows +// the annotation "lark:policy_denied_source". +// +// This indirection lets us avoid an import cycle between hook and +// pruning packages. +func populateInvocationDenial(inv *invocation, c *cobra.Command) { + const layerKey = "lark:policy_denied_layer" + const sourceKey = "lark:policy_denied_source" + if c.Annotations == nil { + return + } + layer, ok := c.Annotations[layerKey] + if !ok || layer == "" { + return + } + source := c.Annotations[sourceKey] + inv.setDenial(layer, source) +} diff --git a/internal/hook/install_default.go b/internal/hook/install_default.go new file mode 100644 index 000000000..2c382a76e --- /dev/null +++ b/internal/hook/install_default.go @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import "os" + +// defaultStderr is the real os.Stderr writer. Kept in a separate file so +// tests can replace `stderr` (in install.go) with a buffer without +// shadowing this variable. +var defaultStderr = os.Stderr //nolint:forbidigo // framework-level fallback writer; hooks fire before IOStreams plumbing is available diff --git a/internal/hook/install_test.go b/internal/hook/install_test.go new file mode 100644 index 000000000..7f11f2897 --- /dev/null +++ b/internal/hook/install_test.go @@ -0,0 +1,397 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" + "github.com/larksuite/cli/internal/output" +) + +// fakeViewSource is a minimal CommandView for tests -- it ignores the +// cobra command and returns a fixed view. +type fakeViewSource struct{ view platform.CommandView } + +func (f fakeViewSource) View(*cobra.Command) platform.CommandView { return f.view } + +type fakeView struct { + path string + risk string +} + +func (v fakeView) Path() string { return v.path } +func (v fakeView) Domain() string { return "" } +func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.risk != "" } +func (v fakeView) Identities() []platform.Identity { return nil } +func (v fakeView) Annotation(string) (string, bool) { return "", false } + +func makeLeaf(use string) *cobra.Command { + return &cobra.Command{Use: use, RunE: func(*cobra.Command, []string) error { return nil }} +} + +// Observers fire on Before AND After even when RunE returns an error. +// This is the failure-path observability contract -- After must always +// run so audit hooks see completion regardless of outcome. +func TestInstall_observersBeforeAndAfterAlwaysRun(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error { + return errors.New("boom") + }} + root.AddCommand(leaf) + + reg := hook.NewRegistry() + + var seen []string + reg.AddObserver(hook.ObserverEntry{ + Name: "before", When: platform.Before, Selector: platform.All(), + Fn: func(_ context.Context, inv platform.Invocation) { + seen = append(seen, fmt.Sprintf("before:err=%v", inv.Err())) + }, + }) + reg.AddObserver(hook.ObserverEntry{ + Name: "after", When: platform.After, Selector: platform.All(), + Fn: func(_ context.Context, inv platform.Invocation) { + seen = append(seen, fmt.Sprintf("after:err=%v", inv.Err())) + }, + }) + + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}}) + + err := leaf.RunE(leaf, nil) + if err == nil || err.Error() != "boom" { + t.Fatalf("expected RunE to return original error, got %v", err) + } + + wantBefore := "before:err=" // before fires with Err still nil + wantAfter := "after:err=boom" // after sees the failed RunE error + if len(seen) != 2 || seen[0] != wantBefore || seen[1] != wantAfter { + t.Fatalf("observer ordering / Err propagation broken, got %v", seen) + } +} + +// Wrap chain composes outermost-first (registration order). A regression +// that inverts the composition would change which Wrapper short-circuits +// first for safety-sensitive layers. +func TestInstall_wrapperChainOrder(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + var order []string + leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error { + order = append(order, "RunE") + return nil + }} + root.AddCommand(leaf) + + reg := hook.NewRegistry() + reg.AddWrapper(hook.WrapperEntry{ + Name: "outer", Selector: platform.All(), + Fn: func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + order = append(order, "outer-before") + err := next(ctx, inv) + order = append(order, "outer-after") + return err + } + }, + }) + reg.AddWrapper(hook.WrapperEntry{ + Name: "inner", Selector: platform.All(), + Fn: func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + order = append(order, "inner-before") + err := next(ctx, inv) + order = append(order, "inner-after") + return err + } + }, + }) + + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}}) + if err := leaf.RunE(leaf, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + want := []string{"outer-before", "inner-before", "RunE", "inner-after", "outer-after"} + if !equalStrings(order, want) { + t.Fatalf("Wrapper order = %v, want %v", order, want) + } +} + +// Denial guard physical isolation: the most safety-critical invariant. +// A denied command must NEVER reach a Wrap chain. We register a Wrap +// that, given the chance, would silently allow the call (return nil, +// don't call next, no AbortError). The guard must skip Wrap entirely +// so the denyStub's error reaches the caller. +// +// Without this guarantee, any plugin Wrap matching All() could +// bypass user policy / strict-mode denials. +func TestInstall_denialGuard_physicalIsolation(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + denyStubCalled := false + leaf := &cobra.Command{ + Use: "+forbidden", + RunE: func(*cobra.Command, []string) error { + denyStubCalled = true + return errors.New("CommandPruned: this is the denyStub") + }, + Annotations: map[string]string{ + "lark:policy_denied_layer": "policy", + "lark:policy_denied_source": "yaml", + }, + } + root.AddCommand(leaf) + + reg := hook.NewRegistry() + + maliciousWrapCalled := false + reg.AddWrapper(hook.WrapperEntry{ + Name: "malicious", Selector: platform.All(), + Fn: func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + maliciousWrapCalled = true + return nil // suppress the denial + } + }, + }) + + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+forbidden"}}) + + err := leaf.RunE(leaf, nil) + if maliciousWrapCalled { + t.Errorf("denial guard violated: Wrap was invoked on a denied command") + } + if !denyStubCalled { + t.Errorf("denyStub (original RunE) should still run on the denial path") + } + if err == nil { + t.Fatalf("denyStub error must propagate, got nil") + } +} + +// Observer panics must not break the main flow. The guard converts the +// panic to a stderr warning and continues; the command still runs. +func TestInstall_observerPanicIsolated(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + runECalled := false + leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error { + runECalled = true + return nil + }} + root.AddCommand(leaf) + + reg := hook.NewRegistry() + reg.AddObserver(hook.ObserverEntry{ + Name: "buggy", When: platform.Before, Selector: platform.All(), + Fn: func(context.Context, platform.Invocation) { + panic("plugin author wrote bad code") + }, + }) + + // Capture stderr to make sure the warning was emitted. Restore the + // previous sink so a subsequent test isn't stuck writing into our + // discarded buffer. + t.Cleanup(hook.SetStderrForTesting(&bytes.Buffer{})) // discard + + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}}) + if err := leaf.RunE(leaf, nil); err != nil { + t.Fatalf("RunE should still succeed when an Observer panicked, got %v", err) + } + if !runECalled { + t.Errorf("RunE must execute despite Observer panic") + } +} + +// A Wrapper returning AbortError surfaces as *output.ExitError with +// type="hook" so cmd/root.go's envelope writer can serialise it. +func TestInstall_abortErrorBecomesExitError(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + leaf := makeLeaf("+x") + root.AddCommand(leaf) + + reg := hook.NewRegistry() + reg.AddWrapper(hook.WrapperEntry{ + Name: "rejecter", Selector: platform.All(), + Fn: func(_ platform.Handler) platform.Handler { + return func(context.Context, platform.Invocation) error { + return &platform.AbortError{ + HookName: "rejecter", + Reason: "policy says no", + } + } + }, + }) + + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}}) + + err := leaf.RunE(leaf, nil) + if err == nil { + t.Fatalf("Wrap aborted; expected error") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("AbortError must convert to *output.ExitError, got %T %+v", err, err) + } + if exitErr.Detail.Type != "hook" { + t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + } + detail := exitErr.Detail.Detail.(map[string]any) + if detail["reason_code"] != "aborted" || detail["hook_name"] != "rejecter" { + t.Errorf("detail = %+v", detail) + } + // The original AbortError must still be reachable via errors.As. + var ab *platform.AbortError + if !errors.As(err, &ab) { + t.Errorf("error chain should expose *platform.AbortError") + } +} + +// namespacedWrap must not mutate a shared *AbortError. A plugin author +// might construct a sentinel at package scope and return it from +// multiple Wrap invocations; mutating it would let attribution leak +// across concurrent command runs and would also race. +// +// Production path test: drive a real cobra.Command through Install +// so namespacedWrap inside install.go is exercised. The plugin returns +// the same sentinel pointer twice. Both observed envelopes must have +// the framework-namespaced HookName, but the sentinel's own HookName +// must remain whatever the plugin originally set. +func TestInstall_namespacedWrap_doesNotMutateSentinel(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + leafA := makeLeaf("+a") + leafB := makeLeaf("+b") + root.AddCommand(leafA) + root.AddCommand(leafB) + + sentinel := &platform.AbortError{HookName: "sentinel-original", Reason: "no"} + + reg := hook.NewRegistry() + // Two Wrappers, different namespaced names, return the SAME + // sentinel. + reg.AddWrapper(hook.WrapperEntry{ + Name: "plugin-a.wrap", + Selector: platform.ByCommandPath("+a"), + Fn: func(platform.Handler) platform.Handler { + return func(context.Context, platform.Invocation) error { return sentinel } + }, + }) + reg.AddWrapper(hook.WrapperEntry{ + Name: "plugin-b.wrap", + Selector: platform.ByCommandPath("+b"), + Fn: func(platform.Handler) platform.Handler { + return func(context.Context, platform.Invocation) error { return sentinel } + }, + }) + + hook.Install(root, reg, fakeViewSourceByPath{}) + + // Invoke both leaves. + errA := leafA.RunE(leafA, nil) + errB := leafB.RunE(leafB, nil) + + // Sentinel must remain untouched: the framework must copy before + // rewriting HookName. + if sentinel.HookName != "sentinel-original" { + t.Errorf("sentinel AbortError was mutated: HookName = %q", sentinel.HookName) + } + + // Each invocation's envelope must carry the correct namespace -- + // proving the framework DID set the right name on its own copy. + checkHookName(t, errA, "plugin-a.wrap") + checkHookName(t, errB, "plugin-b.wrap") +} + +// fakeViewSourceByPath returns a CommandView whose Path matches the +// leaf's Use field (so ByCommandPath selectors discriminate). +type fakeViewSourceByPath struct{} + +func (fakeViewSourceByPath) View(c *cobra.Command) platform.CommandView { + return fakeView{path: c.Use} +} + +func checkHookName(t *testing.T, err error, want string) { + t.Helper() + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected ExitError, got %T", err) + } + detail := exitErr.Detail.Detail.(map[string]any) + if detail["hook_name"] != want { + t.Errorf("hook_name = %v, want %v", detail["hook_name"], want) + } +} + +// A Before observer mutating inv.Args() must not affect what the +// original RunE sees: pins the slice-level read-only contract. +func TestInstall_argsNotMutableByObserver(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + + var seenByRunE []string + leaf := &cobra.Command{ + Use: "+echo", + RunE: func(_ *cobra.Command, args []string) error { + seenByRunE = append([]string(nil), args...) + return nil + }, + } + root.AddCommand(leaf) + + reg := hook.NewRegistry() + reg.AddObserver(hook.ObserverEntry{ + Name: "tamper", When: platform.Before, Selector: platform.All(), + Fn: func(_ context.Context, inv platform.Invocation) { + got := inv.Args() + if len(got) > 0 { + got[0] = "HIJACKED" + } + }, + }) + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+echo"}}) + + originalArgs := []string{"hello", "world"} + if err := leaf.RunE(leaf, originalArgs); err != nil { + t.Fatalf("RunE returned %v", err) + } + if !equalStrings(seenByRunE, originalArgs) { + t.Fatalf("RunE saw mutated args: got %v, want %v", seenByRunE, originalArgs) + } + if originalArgs[0] != "hello" { + t.Fatalf("caller's original args were mutated: %v", originalArgs) + } +} + +// Root command (no parent) must never be wrapped -- it dispatches help +// and other framework concerns. The root has no RunE so we instead +// verify the root's children are wrapped while the root itself remains +// untouched (RunE stays nil). +func TestInstall_rootStaysUntouched(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + leaf := makeLeaf("+x") + root.AddCommand(leaf) + reg := hook.NewRegistry() + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}}) + if root.RunE != nil { + t.Fatalf("root.RunE should remain nil after Install") + } + if leaf.RunE == nil { + t.Fatalf("child leaf.RunE must remain non-nil (wrapped)") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/hook/invocation.go b/internal/hook/invocation.go new file mode 100644 index 000000000..804755bc0 --- /dev/null +++ b/internal/hook/invocation.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import ( + "time" + + "github.com/larksuite/cli/extension/platform" +) + +// invocation is the framework-side concrete implementation of +// platform.Invocation. All setters are unexported so plugin code +// (which only sees the platform.Invocation interface) cannot mutate +// state. +type invocation struct { + cmd platform.CommandView + args []string + started time.Time + err error + + denied bool + layer string + source string +} + +// newInvocation copies args so the read-only platform.Invocation +// contract holds at the slice level: a hook cannot mutate the args +// the original RunE will see. +func newInvocation(cmd platform.CommandView, args []string) *invocation { + argsCopy := append([]string(nil), args...) + return &invocation{ + cmd: cmd, + args: argsCopy, + started: time.Now(), + } +} + +// --- platform.Invocation read interface --- + +func (i *invocation) Cmd() platform.CommandView { return i.cmd } + +// Args returns a fresh copy every call; see newInvocation. +func (i *invocation) Args() []string { + out := make([]string, len(i.args)) + copy(out, i.args) + return out +} +func (i *invocation) Started() time.Time { return i.started } +func (i *invocation) Err() error { return i.err } + +func (i *invocation) DeniedByPolicy() bool { return i.denied } +func (i *invocation) DenialLayer() string { return i.layer } +func (i *invocation) DenialPolicySource() string { + return i.source +} + +// --- framework-internal setters (unexported) --- + +func (i *invocation) setDenial(layer, source string) { + i.denied = true + i.layer = layer + i.source = source +} + +func (i *invocation) setErr(err error) { + i.err = err +} diff --git a/internal/hook/registry.go b/internal/hook/registry.go new file mode 100644 index 000000000..90235c270 --- /dev/null +++ b/internal/hook/registry.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import ( + "context" + "sync" + + "github.com/larksuite/cli/extension/platform" +) + +// ObserverEntry stores one Observer registration. The full hook name +// (already namespaced with plugin prefix by the caller) lets diagnostic +// output point at the responsible plugin. +type ObserverEntry struct { + Name string + When platform.When + Selector platform.Selector + Fn platform.Observer +} + +// WrapperEntry stores one Wrapper registration. Wrappers compose in +// registration order; the outermost (registered first) runs first. +type WrapperEntry struct { + Name string + Selector platform.Selector + Fn platform.Wrapper +} + +// LifecycleEntry stores one lifecycle handler. Selector is unused +// (lifecycle events are global), but Name is preserved for diagnostics. +type LifecycleEntry struct { + Name string + Event platform.LifecycleEvent + Fn platform.LifecycleHandler +} + +// Registry holds all registered hooks. The framework constructs one +// Registry per binary execution; concurrent reads after Install +// commits are safe because the maps are not mutated thereafter. Writes +// (during Install) are serialised by the internalplatform. +type Registry struct { + mu sync.RWMutex + + observers []ObserverEntry + wrappers []WrapperEntry + lifecycles []LifecycleEntry +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { return &Registry{} } + +// Observers returns a snapshot of all registered observers. Order is +// registration order. Diagnostic commands (config plugins show) call +// this to enumerate every hook attached to the binary. +func (r *Registry) Observers() []ObserverEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]ObserverEntry, len(r.observers)) + copy(out, r.observers) + return out +} + +// Wrappers returns a snapshot of all registered wrappers. Order is +// registration order (outermost first). +func (r *Registry) Wrappers() []WrapperEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]WrapperEntry, len(r.wrappers)) + copy(out, r.wrappers) + return out +} + +// Lifecycles returns a snapshot of all registered lifecycle handlers. +func (r *Registry) Lifecycles() []LifecycleEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]LifecycleEntry, len(r.lifecycles)) + copy(out, r.lifecycles) + return out +} + +// AddObserver registers an Observer. Caller is responsible for namespacing +// (the platformhost does this). Nil fn is silently skipped -- the staging +// Registrar should reject invalid registrations before this layer. +func (r *Registry) AddObserver(e ObserverEntry) { + if e.Fn == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.observers = append(r.observers, e) +} + +// AddWrapper registers a Wrapper. +func (r *Registry) AddWrapper(e WrapperEntry) { + if e.Fn == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.wrappers = append(r.wrappers, e) +} + +// AddLifecycle registers a LifecycleHandler. +func (r *Registry) AddLifecycle(e LifecycleEntry) { + if e.Fn == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.lifecycles = append(r.lifecycles, e) +} + +// MatchingObservers returns the observers whose selector matches the +// command at the given When stage. Result is a slice (not a generator) +// so callers can iterate without holding the registry lock. +func (r *Registry) MatchingObservers(cmd platform.CommandView, when platform.When) []ObserverEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]ObserverEntry, 0, len(r.observers)) + for _, e := range r.observers { + if e.When == when && e.Selector != nil && e.Selector(cmd) { + out = append(out, e) + } + } + return out +} + +// MatchingWrappers returns the wrappers whose selector matches the +// command. Order matches registration order. +func (r *Registry) MatchingWrappers(cmd platform.CommandView) []WrapperEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]WrapperEntry, 0, len(r.wrappers)) + for _, e := range r.wrappers { + if e.Selector != nil && e.Selector(cmd) { + out = append(out, e) + } + } + return out +} + +// LifecycleHandlers returns handlers for a given event in registration +// order. +func (r *Registry) LifecycleHandlers(event platform.LifecycleEvent) []LifecycleEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]LifecycleEntry, 0, len(r.lifecycles)) + for _, e := range r.lifecycles { + if e.Event == event { + out = append(out, e) + } + } + return out +} + +// ComposeWrappers folds a slice of Wrappers into a single Wrapper that +// applies them in registration order (outermost first). Empty slice +// returns the identity Wrapper (next as-is). Inspired by +// grpc.ChainUnaryInterceptor. +func ComposeWrappers(ws []platform.Wrapper) platform.Wrapper { + if len(ws) == 0 { + return identityWrapper + } + return func(next platform.Handler) platform.Handler { + // Build from the inside out so the first registered Wrapper + // ends up outermost. + for i := len(ws) - 1; i >= 0; i-- { + next = ws[i](next) + } + return next + } +} + +// identityWrapper is the no-op wrapper used when there are no matching +// Wrappers for a command -- callers can always compose into +// next(ctx, inv) without a nil check. +func identityWrapper(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + return next(ctx, inv) + } +} diff --git a/internal/hook/testing.go b/internal/hook/testing.go new file mode 100644 index 000000000..611257e1b --- /dev/null +++ b/internal/hook/testing.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import "io" + +// SetStderrForTesting redirects the hook layer's warning output to a +// custom writer and returns a restore function the caller MUST defer +// (or pass to `t.Cleanup`). Without the restore step, a later test in +// the same binary would inherit the override and either race on a +// shared bytes.Buffer or write user-visible garbage into a real test +// stderr. +// +// Production code never calls this; the default writer is os.Stderr +// via defaultStderr. +func SetStderrForTesting(w io.Writer) (restore func()) { + prev := stderr + stderr = func() interface{ Write(p []byte) (int, error) } { + return w + } + return func() { stderr = prev } +} diff --git a/internal/hook/walk.go b/internal/hook/walk.go new file mode 100644 index 000000000..fe5b0dbf9 --- /dev/null +++ b/internal/hook/walk.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import "github.com/spf13/cobra" + +// walkTree applies fn to every command in the tree, depth-first. Hidden +// commands are visited too -- they can still be invoked. +func walkTree(root *cobra.Command, fn func(*cobra.Command)) { + if root == nil { + return + } + fn(root) + for _, c := range root.Commands() { + walkTree(c, fn) + } +} diff --git a/internal/platform/doc.go b/internal/platform/doc.go new file mode 100644 index 000000000..1a70e594c --- /dev/null +++ b/internal/platform/doc.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package platformhost is the bootstrap-time orchestrator that turns the +// global plugin registry (extension/platform.RegisteredPlugins) into: +// +// - a populated internal/hook.Registry (Observer / Wrapper / Lifecycle) +// - a list of cmdpolicy.PluginRule contributions (one per plugin that +// called r.Restrict) +// +// Two key invariants: +// +// - **Atomic install.** A plugin's Install() runs against a staging +// Registrar; only when Install returns nil AND validateSelf passes +// does the host commit the staged hooks/rule. Partial install never +// reaches the live Registry, so a half-loaded plugin cannot leave +// stale Observer / Wrap entries behind. +// +// - **FailurePolicy honoured.** Each plugin declares FailOpen or +// FailClosed. FailOpen plugins are skipped on error (warning to +// stderr); FailClosed plugins abort the whole bootstrap. The +// framework also enforces the Restricts↔FailClosed consistency +// contract (a Restricts=true plugin with FailOpen would be a +// silent security hole and is rejected during install). +// +// The host returns: +// +// - a *hook.Registry ready to install on the command tree +// - a []cmdpolicy.PluginRule for the pruning resolver +// - an error when a FailClosed plugin failed +package internalplatform diff --git a/internal/platform/error.go b/internal/platform/error.go new file mode 100644 index 000000000..8ee037aa6 --- /dev/null +++ b/internal/platform/error.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import "fmt" + +// PluginInstallError is the typed install-time failure. ReasonCode comes +// from the closed enum in the design doc (section 5.3 reason_code +// table). Cause carries the underlying error, if any, so consumers can +// errors.As to inspect it. +type PluginInstallError struct { + PluginName string + ReasonCode string + Reason string + Cause error +} + +func (e *PluginInstallError) Error() string { + prefix := fmt.Sprintf("plugin %q (%s)", e.PluginName, e.ReasonCode) + if e.Reason != "" { + prefix += ": " + e.Reason + } + if e.Cause != nil { + prefix += ": " + e.Cause.Error() + } + return prefix +} + +func (e *PluginInstallError) Unwrap() error { return e.Cause } + +// ReasonCodes for PluginInstallError. The closed enum is referenced by +// the design doc's hard-constraint #15 (reason_code enum closure) and +// drives the JSON envelope's error.detail.reason_code field. +const ( + ReasonInvalidPluginName = "invalid_plugin_name" + ReasonPluginNamePanic = "plugin_name_panic" + ReasonInvalidHookName = "invalid_hook_name" + ReasonDuplicateHookName = "duplicate_hook_name" + ReasonInvalidHookRegister = "invalid_hook_registration" + ReasonInvalidRule = "invalid_rule" + ReasonDoubleRestrict = "double_restrict" + ReasonRestrictsMismatch = "restricts_mismatch" + ReasonCapabilityUnmet = "capability_unmet" + ReasonCapabilitiesPanic = "capabilities_panic" + // ReasonInvalidCapability flags a plugin authoring error in + // Capabilities() output -- e.g. a syntactically malformed + // RequiredCLIVersion string. This is distinct from + // ReasonCapabilityUnmet (legitimate version mismatch): an authoring + // bug must NOT be hidden by FailurePolicy=FailOpen, so this code is + // classified as untrusted-config and aborts unconditionally. + ReasonInvalidCapability = "invalid_capability" + ReasonInstallFailed = "install_failed" + ReasonInstallPanic = "install_panic" + ReasonDuplicatePluginName = "duplicate_plugin_name" + ReasonMultipleRestricts = "multiple_restrict_plugins" +) diff --git a/internal/platform/host.go b/internal/platform/host.go new file mode 100644 index 000000000..2f13cf59d --- /dev/null +++ b/internal/platform/host.go @@ -0,0 +1,344 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import ( + "errors" + "fmt" + "io" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/hook" +) + +// PluginInfo is the metadata of a successfully-installed plugin, +// captured at install time so diagnostic commands (config plugins show) +// can enumerate plugins without re-calling potentially panic-prone +// plugin methods at display time. +type PluginInfo struct { + Name string + Version string + Capabilities platform.Capabilities +} + +// InstallResult is the output of InstallAll. Registry is ready for +// hook.Install; PluginRules feeds into cmdpolicy.Resolve as the +// "plugin contribution" half of the resolver input. Plugins lists +// every plugin that committed successfully (FailOpen-skipped plugins +// are absent), for downstream diagnostics. +type InstallResult struct { + Registry *hook.Registry + PluginRules []cmdpolicy.PluginRule + Plugins []PluginInfo +} + +// InstallAll runs every registered plugin through the staging +// Registrar, validates, and commits the survivors. FailOpen plugins +// that fail are skipped with a warning; the first FailClosed failure +// stops the loop and returns the error. +// +// Plugins are processed in registration order so the result is +// deterministic. +// +// errOut receives warnings about FailOpen plugin skips. nil errOut +// means warnings are dropped (useful in tests). +func InstallAll(plugins []platform.Plugin, errOut io.Writer) (*InstallResult, error) { + if errOut == nil { + errOut = io.Discard + } + result := &InstallResult{ + Registry: hook.NewRegistry(), + } + + // Detect duplicate Plugin.Name. We do this up-front so the error + // surfaces before any Install runs; design hard-constraint #7 + // treats this as configuration error (fail-closed regardless of + // individual FailurePolicy). + if err := detectDuplicateNames(plugins); err != nil { + return nil, err + } + + for _, p := range plugins { + name, nameErr := safeCallName(p) + if nameErr != nil { + // Fail-closed on bad Name: we don't know the plugin's + // FailurePolicy yet (it's behind Capabilities, and we + // cannot trust Capabilities() before Name() succeeds). + return nil, nameErr + } + if err := installOne(name, p, result); err != nil { + // Some errors must abort regardless of FailurePolicy + // because they imply the plugin's FailurePolicy itself + // cannot be trusted (e.g. the consistency check between + // Restricts and FailClosed failed). + if isUntrustedConfigError(err) { + return nil, err + } + policy := readFailurePolicy(p) + switch policy { + case platform.FailClosed: + return nil, err + default: + fmt.Fprintf(errOut, "warning: plugin %q skipped: %v\n", name, err) + continue + } + } + } + + return result, nil +} + +// isUntrustedConfigError flags errors where the plugin's declared +// FailurePolicy is itself part of the misconfiguration. For these the +// host MUST abort unconditionally; honouring an FailOpen declaration on +// a misconfigured Restricts plugin would defeat the whole point of the +// consistency check. +func isUntrustedConfigError(err error) bool { + var pi *PluginInstallError + if !errors.As(err, &pi) { + return false + } + return pi.ReasonCode == ReasonRestrictsMismatch || + pi.ReasonCode == ReasonInvalidPluginName || + pi.ReasonCode == ReasonPluginNamePanic || + pi.ReasonCode == ReasonDuplicatePluginName || + pi.ReasonCode == ReasonInvalidCapability +} + +// installOne handles a single plugin: build a staging Registrar, call +// Install, run validateSelf, and on success commit to the live +// Registry / PluginRules. Any error means staged data is discarded. +func installOne(name string, p platform.Plugin, result *InstallResult) error { + caps, capsErr := safeCallCapabilities(p) + if capsErr != nil { + return capsErr + } + + // FailurePolicy is a closed enum. An out-of-range value almost + // always means the plugin author shipped FailurePolicy(2)/etc. by + // mistake, and the host's switch on caps.FailurePolicy below would + // silently treat the unknown value as FailOpen — defeating the + // security boundary the policy was meant to express. Reject up + // front with ReasonInvalidCapability (classified as + // untrusted-config, so the abort is unconditional). + if caps.FailurePolicy != platform.FailOpen && caps.FailurePolicy != platform.FailClosed { + return &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonInvalidCapability, + Reason: fmt.Sprintf("FailurePolicy=%d is not a recognised value (expected FailOpen or FailClosed)", + caps.FailurePolicy), + } + } + + // Strict consistency check: Restricts=true must pair with + // FailClosed (design hard-constraint #6). + if caps.Restricts && caps.FailurePolicy != platform.FailClosed { + return &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonRestrictsMismatch, + Reason: "Restricts=true requires FailurePolicy=FailClosed", + } + } + + // Version compatibility check. Two distinct failure modes: + // + // 1. Parse error (constraint is malformed, e.g. ">=abc") + // -> ReasonInvalidCapability, classified as untrusted-config + // so the host aborts unconditionally. This is a plugin + // authoring bug; FailurePolicy must NOT mask it. + // + // 2. Legitimate version mismatch (constraint parses fine but + // current CLI does not satisfy it) + // -> ReasonCapabilityUnmet, honours FailurePolicy. A FailOpen + // plugin announcing ">=2.0" against a 1.x CLI is skipped + // with a warning; a FailClosed plugin aborts. + if ok, err := satisfiesRequiredCLIVersion(currentCLIVersion(), caps.RequiredCLIVersion); err != nil { + return &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonInvalidCapability, + Reason: err.Error(), + } + } else if !ok { + return &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonCapabilityUnmet, + Reason: fmt.Sprintf("CLI version %q does not satisfy plugin requirement %q", + currentCLIVersion(), caps.RequiredCLIVersion), + } + } + + staging := newStagingRegistrar(name) + if err := safeCallInstall(p, staging); err != nil { + // Don't double-wrap typed PluginInstallError -- safeCallInstall + // already produces install_panic for recovered panics, and a + // re-wrap would bury the precise reason_code under + // install_failed. + var pi *PluginInstallError + if errors.As(err, &pi) { + return err + } + return &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonInstallFailed, + Reason: "Install returned error", + Cause: err, + } + } + + if err := staging.validateSelf(caps); err != nil { + return err + } + + // Commit staged data atomically. + for _, e := range staging.stagedObservers { + result.Registry.AddObserver(e) + } + for _, e := range staging.stagedWrappers { + result.Registry.AddWrapper(e) + } + for _, e := range staging.stagedLifecycles { + result.Registry.AddLifecycle(e) + } + if staging.rule != nil { + result.PluginRules = append(result.PluginRules, cmdpolicy.PluginRule{ + PluginName: name, + Rule: staging.rule, + }) + } + + // Record the plugin in the inventory. Version is fetched here under + // a recover-wrapped helper so a plugin's Version() panic does not + // abort the install we just committed. + result.Plugins = append(result.Plugins, PluginInfo{ + Name: name, + Version: safeCallVersion(p), + Capabilities: caps, + }) + return nil +} + +// safeCallVersion mirrors safeCallName but for Plugin.Version. Failures +// degrade to the empty string -- Version is informational, not a hard +// contract field, so we never want it to abort installation. +func safeCallVersion(p platform.Plugin) (v string) { + defer func() { + if r := recover(); r != nil { + v = "" + } + }() + return p.Version() +} + +// readFailurePolicy reads Capabilities and returns the policy, falling +// back to FailClosed if Capabilities() panics. Defensive default: we +// assume the worst-case (safety-sensitive) when we cannot read the +// declaration. +// +// **Implementation note**: FailClosed must be the value set BEFORE the +// panic-prone call. The zero value of platform.FailurePolicy is +// FailOpen, so a "just return after recover" pattern would silently +// flip the safe-default to FailOpen on panic -- the opposite of what +// the comment claims. +func readFailurePolicy(p platform.Plugin) (policy platform.FailurePolicy) { + policy = platform.FailClosed + defer func() { _ = recover() }() + policy = p.Capabilities().FailurePolicy + return +} + +// safeCallName recovers from a panic in Plugin.Name() and surfaces it +// as a typed PluginInstallError. Without recovery, a buggy plugin could +// crash the binary before main has a chance to emit a JSON envelope. +func safeCallName(p platform.Plugin) (string, error) { + var ( + name string + err error + ) + func() { + defer func() { + if r := recover(); r != nil { + err = &PluginInstallError{ + PluginName: "", + ReasonCode: ReasonPluginNamePanic, + Reason: fmt.Sprintf("Plugin.Name() panicked: %v", r), + } + } + }() + name = p.Name() + }() + if err != nil { + return "", err + } + if !hookNamePattern.MatchString(name) { + return "", &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonInvalidPluginName, + Reason: fmt.Sprintf("Plugin.Name() %q must match ^[a-z0-9][a-z0-9-]*$ (no dots)", name), + } + } + return name, nil +} + +// safeCallCapabilities mirrors safeCallName for Capabilities(). +func safeCallCapabilities(p platform.Plugin) (caps platform.Capabilities, err error) { + defer func() { + if r := recover(); r != nil { + err = &PluginInstallError{ + PluginName: pluginNameOrPlaceholder(p), + ReasonCode: ReasonCapabilitiesPanic, + Reason: fmt.Sprintf("Plugin.Capabilities() panicked: %v", r), + } + } + }() + caps = p.Capabilities() + return caps, nil +} + +// safeCallInstall mirrors safeCallName for Install(). Install panics +// become install_panic errors, not crashes. +func safeCallInstall(p platform.Plugin, r platform.Registrar) (err error) { + defer func() { + if rec := recover(); rec != nil { + err = &PluginInstallError{ + PluginName: pluginNameOrPlaceholder(p), + ReasonCode: ReasonInstallPanic, + Reason: fmt.Sprintf("Install panicked: %v", rec), + } + } + }() + return p.Install(r) +} + +func pluginNameOrPlaceholder(p platform.Plugin) string { + defer func() { _ = recover() }() + if n := p.Name(); n != "" { + return n + } + return "" +} + +// detectDuplicateNames scans the plugin slice for repeated Plugin.Name +// values. Returns a typed PluginInstallError on the first duplicate so +// the bootstrap aborts. +func detectDuplicateNames(plugins []platform.Plugin) error { + seen := map[string]bool{} + for _, p := range plugins { + name, err := safeCallName(p) + if err != nil { + // Don't double-report: let installOne handle naming + // errors per-plugin so we get the same code path. + continue + } + if seen[name] { + return &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonDuplicatePluginName, + Reason: fmt.Sprintf("duplicate Plugin.Name() %q across plugins", name), + } + } + seen[name] = true + } + return nil +} diff --git a/internal/platform/host_test.go b/internal/platform/host_test.go new file mode 100644 index 000000000..13b1f574e --- /dev/null +++ b/internal/platform/host_test.go @@ -0,0 +1,391 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/extension/platform" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// happyPlugin is a textbook plugin: declares Capabilities, calls a few +// Registrar methods, returns nil. The install pipeline must accept it. +type happyPlugin struct{ name string } + +func (p happyPlugin) Name() string { return p.name } +func (p happyPlugin) Version() string { return "1.0.0" } +func (p happyPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + FailurePolicy: platform.FailOpen, + } +} +func (p happyPlugin) Install(r platform.Registrar) error { + r.Observe(platform.Before, "audit-pre", platform.All(), + func(context.Context, platform.Invocation) {}) + r.Wrap("policy", platform.All(), + func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + return next(ctx, inv) + } + }) + r.On(platform.Shutdown, "flush", + func(context.Context, *platform.LifecycleContext) error { return nil }) + return nil +} + +func TestInstallAll_happyPlugin(t *testing.T) { + result, err := internalplatform.InstallAll([]platform.Plugin{happyPlugin{name: "audit"}}, nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + if result.Registry == nil { + t.Fatalf("registry should be populated") + } + if len(result.PluginRules) != 0 { + t.Errorf("happy plugin did not call Restrict; rules should be empty") + } + // Cross-check: observers, wrappers, lifecycles got staged through to the live Registry. + if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 { + t.Errorf("Before observer not committed") + } + if len(result.Registry.MatchingWrappers(fakeView{})) != 1 { + t.Errorf("Wrapper not committed") + } + if len(result.Registry.LifecycleHandlers(platform.Shutdown)) != 1 { + t.Errorf("Shutdown lifecycle not committed") + } +} + +// fakeView satisfies platform.CommandView for selector lookups in the +// platformhost tests; All() matches everything so the type can stay +// trivial. +type fakeView struct{} + +func (fakeView) Path() string { return "" } +func (fakeView) Domain() string { return "" } +func (fakeView) Risk() (platform.Risk, bool) { return "", false } +func (fakeView) Identities() []platform.Identity { return nil } +func (fakeView) Annotation(string) (string, bool) { return "", false } + +// A FailClosed plugin whose Install returns an error must abort +// InstallAll. Design hard-constraint #6. +type failClosedPlugin struct{} + +func (failClosedPlugin) Name() string { return "secaudit" } +func (failClosedPlugin) Version() string { return "1.0.0" } +func (failClosedPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + FailurePolicy: platform.FailClosed, + } +} +func (failClosedPlugin) Install(platform.Registrar) error { + return errors.New("upstream unreachable") +} + +func TestInstallAll_failClosedAborts(t *testing.T) { + _, err := internalplatform.InstallAll([]platform.Plugin{failClosedPlugin{}}, nil) + if err == nil { + t.Fatalf("FailClosed install error should abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) { + t.Fatalf("error must be *PluginInstallError, got %T", err) + } + if pi.ReasonCode != internalplatform.ReasonInstallFailed { + t.Errorf("ReasonCode = %q, want install_failed", pi.ReasonCode) + } +} + +// FailOpen install failure logs a warning and skips this plugin; other +// plugins still get installed. +type failOpenPlugin struct{} + +func (failOpenPlugin) Name() string { return "audit-broken" } +func (failOpenPlugin) Version() string { return "1.0.0" } +func (failOpenPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{FailurePolicy: platform.FailOpen} +} +func (failOpenPlugin) Install(platform.Registrar) error { + return errors.New("could not connect") +} + +func TestInstallAll_failOpenSkips(t *testing.T) { + var buf bytes.Buffer + plugins := []platform.Plugin{ + failOpenPlugin{}, + happyPlugin{name: "audit"}, + } + result, err := internalplatform.InstallAll(plugins, &buf) + if err != nil { + t.Fatalf("FailOpen failure must not abort, got %v", err) + } + if !strings.Contains(buf.String(), "audit-broken") { + t.Errorf("FailOpen warning should mention plugin name, got %q", buf.String()) + } + // Second plugin's observer should be present. + if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 { + t.Errorf("happy plugin's observer should still be installed after first plugin skipped") + } +} + +// Restricts=true with FailOpen is a configuration error: a policy +// plugin that silently disappears under FailOpen would erase the +// security boundary. The host must reject this combo BEFORE Install +// runs. +type misconfiguredRestrictPlugin struct{} + +func (misconfiguredRestrictPlugin) Name() string { return "secaudit" } +func (misconfiguredRestrictPlugin) Version() string { return "1.0.0" } +func (misconfiguredRestrictPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + Restricts: true, // policy plugin + FailurePolicy: platform.FailOpen, // contradicts safety contract + } +} +func (misconfiguredRestrictPlugin) Install(platform.Registrar) error { return nil } + +func TestInstallAll_restrictsRequiresFailClosed(t *testing.T) { + _, err := internalplatform.InstallAll([]platform.Plugin{misconfiguredRestrictPlugin{}}, nil) + if err == nil { + t.Fatalf("Restricts+FailOpen must abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonRestrictsMismatch { + t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi) + } +} + +// Restricts=true but Install didn't call r.Restrict -> mismatch. +type lyingRestrictPlugin struct{} + +func (lyingRestrictPlugin) Name() string { return "p" } +func (lyingRestrictPlugin) Version() string { return "1.0.0" } +func (lyingRestrictPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + Restricts: true, + FailurePolicy: platform.FailClosed, + } +} +func (lyingRestrictPlugin) Install(platform.Registrar) error { + // Forgot to call r.Restrict. + return nil +} + +func TestInstallAll_restrictsDeclaredButNotCalled(t *testing.T) { + _, err := internalplatform.InstallAll([]platform.Plugin{lyingRestrictPlugin{}}, nil) + if err == nil { + t.Fatalf("missing Restrict call when declared must fail") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonRestrictsMismatch { + t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi) + } +} + +// Plugin that panics inside Install must NOT crash the binary -- the +// host recovers and converts the panic into a typed install_panic. +type panicInstallPlugin struct{} + +func (panicInstallPlugin) Name() string { return "panicker" } +func (panicInstallPlugin) Version() string { return "1.0.0" } +func (panicInstallPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{FailurePolicy: platform.FailClosed} +} +func (panicInstallPlugin) Install(platform.Registrar) error { + panic("boom") +} + +func TestInstallAll_installPanicRecovered(t *testing.T) { + _, err := internalplatform.InstallAll([]platform.Plugin{panicInstallPlugin{}}, nil) + if err == nil { + t.Fatalf("Install panic should surface as error") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInstallPanic { + t.Fatalf("ReasonCode = %v, want install_panic", pi) + } +} + +// Two plugins with the same Name must abort before any Install runs. +func TestInstallAll_duplicatePluginName(t *testing.T) { + _, err := internalplatform.InstallAll([]platform.Plugin{ + happyPlugin{name: "audit"}, + happyPlugin{name: "audit"}, + }, nil) + if err == nil { + t.Fatalf("duplicate Plugin.Name must abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonDuplicatePluginName { + t.Fatalf("ReasonCode = %v, want duplicate_plugin_name", pi) + } +} + +// Plugin with an invalid Name (contains "." or starts with a hyphen) +// must abort with invalid_plugin_name. The dot ban is critical -- the +// "{plugin}.{hook}" namespace join would become ambiguous if dots were +// allowed inside Plugin.Name(). +type badNamePlugin struct{ n string } + +func (p badNamePlugin) Name() string { return p.n } +func (p badNamePlugin) Version() string { return "1.0.0" } +func (p badNamePlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{FailurePolicy: platform.FailClosed} +} +func (p badNamePlugin) Install(platform.Registrar) error { return nil } + +func TestInstallAll_invalidPluginName(t *testing.T) { + cases := []string{"with.dot", "", "-leading-hyphen", "UPPER"} + for _, name := range cases { + t.Run(name, func(t *testing.T) { + _, err := internalplatform.InstallAll([]platform.Plugin{badNamePlugin{n: name}}, nil) + if err == nil { + t.Fatalf("invalid name %q should abort", name) + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidPluginName { + t.Fatalf("ReasonCode = %v, want invalid_plugin_name", pi) + } + }) + } +} + +// Plugin's Install registers two hooks with the same name -- the +// staging Registrar rejects the second one with duplicate_hook_name. +type duplicateHookPlugin struct{} + +func (duplicateHookPlugin) Name() string { return "dup" } +func (duplicateHookPlugin) Version() string { return "1.0.0" } +func (duplicateHookPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{FailurePolicy: platform.FailClosed} +} +func (duplicateHookPlugin) Install(r platform.Registrar) error { + r.Observe(platform.Before, "x", platform.All(), func(context.Context, platform.Invocation) {}) + r.Observe(platform.After, "x", platform.All(), func(context.Context, platform.Invocation) {}) + return nil +} + +func TestInstallAll_duplicateHookName(t *testing.T) { + _, err := internalplatform.InstallAll([]platform.Plugin{duplicateHookPlugin{}}, nil) + if err == nil { + t.Fatalf("duplicate hookName within same plugin must abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonDuplicateHookName { + t.Fatalf("ReasonCode = %v, want duplicate_hook_name", pi) + } +} + +// Restrict contributes a rule to result.PluginRules so the pruning +// resolver can pick it up. Exercise the full path. +type restrictPlugin struct{ rule *platform.Rule } + +func (p restrictPlugin) Name() string { return "secaudit" } +func (p restrictPlugin) Version() string { return "1.0.0" } +func (p restrictPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + Restricts: true, + FailurePolicy: platform.FailClosed, + } +} +func (p restrictPlugin) Install(r platform.Registrar) error { + r.Restrict(p.rule) + return nil +} + +func TestInstallAll_restrictPropagatesRule(t *testing.T) { + rule := &platform.Rule{ + Name: "secaudit-policy", + MaxRisk: "read", + Allow: []string{"docs/**"}, + Deny: []string{"docs/+delete-doc"}, + Identities: []platform.Identity{"bot"}, + } + result, err := internalplatform.InstallAll([]platform.Plugin{restrictPlugin{rule: rule}}, nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + if len(result.PluginRules) != 1 { + t.Fatalf("expected 1 plugin rule, got %d", len(result.PluginRules)) + } + stored := result.PluginRules[0].Rule + if stored == nil { + t.Fatalf("stored rule is nil") + } + + // stagingRegistrar.Restrict defensively clones the plugin-supplied + // rule so a misbehaving plugin can't mutate it after Install + // returns. The clone must carry identical contents but live on a + // distinct pointer. + if stored == rule { + t.Errorf("stored rule should be a clone, got identical pointer") + } + if stored.Name != rule.Name || stored.MaxRisk != rule.MaxRisk { + t.Errorf("stored rule lost data: %+v", stored) + } + if got, want := len(stored.Allow), len(rule.Allow); got != want { + t.Errorf("stored Allow len = %d, want %d", got, want) + } + + // Verify the clone is actually isolated: mutating the plugin's + // rule after install must not change the stored one. + rule.Allow[0] = "evil/**" + rule.Deny = append(rule.Deny, "extra/**") + if stored.Allow[0] == "evil/**" { + t.Errorf("Allow slice aliased plugin storage") + } + if len(stored.Deny) != 1 { + t.Errorf("Deny slice aliased plugin storage: %v", stored.Deny) + } + + if result.PluginRules[0].PluginName != "secaudit" { + t.Errorf("PluginName = %q", result.PluginRules[0].PluginName) + } +} + +// Atomic install: a plugin whose validation fails AFTER it registered +// some hooks must NOT leak those hooks into the live registry. The +// staging buffer is the atomicity boundary. +type partiallyRegisterThenFailPlugin struct{} + +func (partiallyRegisterThenFailPlugin) Name() string { return "partial" } +func (partiallyRegisterThenFailPlugin) Version() string { return "1.0.0" } +func (partiallyRegisterThenFailPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + Restricts: true, // declares Restrict but won't call it + FailurePolicy: platform.FailClosed, + } +} +func (partiallyRegisterThenFailPlugin) Install(r platform.Registrar) error { + r.Observe(platform.Before, "would-leak", platform.All(), + func(context.Context, platform.Invocation) {}) + // validateSelf will fail because Restricts=true but Restrict + // was not called -- this is the atomic-rollback case. + return nil +} + +func TestInstallAll_atomicRollback(t *testing.T) { + _, err := internalplatform.InstallAll( + []platform.Plugin{partiallyRegisterThenFailPlugin{}, happyPlugin{name: "audit"}}, + nil, + ) + if err == nil { + t.Fatalf("partial plugin should abort (FailClosed)") + } + // We cannot check Registry contents here because InstallAll + // returns nil on failure; the rollback invariant is "nothing the + // failing plugin staged ever reached a live Registry", which is + // proven by the fact that we got nil back. A weaker but useful + // check: even if we passed a happy second plugin, the loop must + // have stopped at the first FailClosed failure. + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) { + t.Fatalf("error must be *PluginInstallError, got %T", err) + } +} diff --git a/internal/platform/inventory.go b/internal/platform/inventory.go new file mode 100644 index 000000000..1127f9f46 --- /dev/null +++ b/internal/platform/inventory.go @@ -0,0 +1,264 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import ( + "strings" + "sync" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" +) + +// HookEntry is the displayable form of one registered hook. +type HookEntry struct { + Name string `json:"name"` + When string `json:"when,omitempty"` // observers only + Event string `json:"event,omitempty"` // lifecycle only +} + +// PluginEntry collects everything one plugin contributed. +type PluginEntry struct { + Name string + Version string + Capabilities CapabilitiesView + + // Rule is non-nil only when the plugin called r.Restrict. + Rule *RuleView + + Observers []HookEntry + Wrappers []HookEntry + Lifecycles []HookEntry +} + +// CapabilitiesView mirrors platform.Capabilities for display. We keep a +// separate struct so the JSON shape stays under our control and does +// not drift with extension/platform. +type CapabilitiesView struct { + Restricts bool `json:"restricts"` + FailurePolicy string `json:"failure_policy"` + RequiredCLIVersion string `json:"required_cli_version,omitempty"` +} + +// NewCapabilitiesView converts a platform.Capabilities value into the +// display struct. +func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView { + return CapabilitiesView{ + Restricts: c.Restricts, + FailurePolicy: failurePolicyLabel(c.FailurePolicy), + RequiredCLIVersion: c.RequiredCLIVersion, + } +} + +func failurePolicyLabel(p platform.FailurePolicy) string { + switch p { + case platform.FailOpen: + return "FailOpen" + case platform.FailClosed: + return "FailClosed" + } + return "" +} + +// RuleView is the displayable form of a Plugin.Restrict contribution. +type RuleView struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Allow []string `json:"allow"` + Deny []string `json:"deny"` + MaxRisk string `json:"max_risk"` + Identities []string `json:"identities"` + AllowUnannotated bool `json:"allow_unannotated"` +} + +// Inventory is the full snapshot. +type Inventory struct { + Plugins []PluginEntry +} + +// PluginInventorySource is the minimum slice of PluginInfo BuildInventory needs. +type PluginInventorySource struct { + Name string + Version string + Capabilities platform.Capabilities +} + +// RuleInventorySource is the minimum slice of cmdpolicy.PluginRule +// BuildInventory needs. Kept as plain strings to avoid an import +// cycle with cmdpolicy (the caller converts platform.Risk / Identity +// to string at the boundary). +type RuleInventorySource struct { + PluginName string + Allow []string + Deny []string + MaxRisk string + Identities []string + RuleName string + Desc string + AllowUnannotated bool +} + +// BuildInventory assembles an Inventory from the parts produced by +// InstallAll: the plugin metadata list, the hook registry (may be nil +// when no hooks were registered), and the plugin rules. +// +// Hooks are attributed to plugins by the namespaced name convention: +// each entry's Name starts with ".", and we group by the +// leading segment up to the first dot. +func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory { + byPlugin := make(map[string]*PluginEntry, len(plugins)) + out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))} + for _, p := range plugins { + entry := PluginEntry{ + Name: p.Name, + Version: p.Version, + Capabilities: NewCapabilitiesView(p.Capabilities), + } + out.Plugins = append(out.Plugins, entry) + } + for i := range out.Plugins { + byPlugin[out.Plugins[i].Name] = &out.Plugins[i] + } + + if registry != nil { + for _, e := range registry.Observers() { + if entry := byPlugin[ownerOf(e.Name)]; entry != nil { + entry.Observers = append(entry.Observers, HookEntry{ + Name: e.Name, + When: whenLabel(e.When), + }) + } + } + for _, e := range registry.Wrappers() { + if entry := byPlugin[ownerOf(e.Name)]; entry != nil { + entry.Wrappers = append(entry.Wrappers, HookEntry{ + Name: e.Name, + }) + } + } + for _, e := range registry.Lifecycles() { + if entry := byPlugin[ownerOf(e.Name)]; entry != nil { + entry.Lifecycles = append(entry.Lifecycles, HookEntry{ + Name: e.Name, + Event: eventLabel(e.Event), + }) + } + } + } + + for _, r := range rules { + if entry := byPlugin[r.PluginName]; entry != nil { + entry.Rule = &RuleView{ + Name: r.RuleName, + Description: r.Desc, + Allow: r.Allow, + Deny: r.Deny, + MaxRisk: r.MaxRisk, + Identities: r.Identities, + AllowUnannotated: r.AllowUnannotated, + } + } + } + return out +} + +// ownerOf extracts the plugin name from a namespaced hook name. The +// platform forbids "." in plugin names, so the first dot is always the +// namespace separator. Names without a dot are returned as-is. +func ownerOf(hookName string) string { + if i := strings.IndexByte(hookName, '.'); i >= 0 { + return hookName[:i] + } + return hookName +} + +func whenLabel(w platform.When) string { + switch w { + case platform.Before: + return "Before" + case platform.After: + return "After" + } + return "" +} + +func eventLabel(e platform.LifecycleEvent) string { + switch e { + case platform.Startup: + return "Startup" + case platform.Shutdown: + return "Shutdown" + } + return "" +} + +// --- Active inventory storage (process-global) --- + +var ( + inventoryMu sync.RWMutex + activeInventory *Inventory +) + +// SetActiveInventory records the inventory built at bootstrap. Called +// once from cmd/policy.go after install + wireHooks complete. +// +// A deep copy is taken so the snapshot is immune to later mutations of +// the input by the caller (or by any other goroutine reading the same +// PluginEntry slice). Without deep-copy, the shallow `cp := *inv` +// previously still aliased Plugins / observer / wrapper / lifecycle +// slices and the embedded RuleView's slice fields. +func SetActiveInventory(inv *Inventory) { + inventoryMu.Lock() + defer inventoryMu.Unlock() + if inv == nil { + activeInventory = nil + return + } + activeInventory = cloneInventory(inv) +} + +// GetActiveInventory returns a deep copy of the inventory, or nil if +// bootstrap has not finished. Same reasoning as SetActiveInventory: +// returning a shallow copy would let callers reach into the stored +// global through any of the embedded slices. +func GetActiveInventory() *Inventory { + inventoryMu.RLock() + defer inventoryMu.RUnlock() + if activeInventory == nil { + return nil + } + return cloneInventory(activeInventory) +} + +// cloneInventory deep-copies every level the snapshot exposes: +// top-level struct, Plugins slice, each PluginEntry's hook slices, and +// the rule's slice fields. The hook entries themselves are value types +// so the slice copy already disjoints them. +func cloneInventory(in *Inventory) *Inventory { + if in == nil { + return nil + } + out := &Inventory{ + Plugins: make([]PluginEntry, len(in.Plugins)), + } + for i, p := range in.Plugins { + entry := PluginEntry{ + Name: p.Name, + Version: p.Version, + Capabilities: p.Capabilities, + } + if p.Rule != nil { + rv := *p.Rule + rv.Allow = append([]string(nil), p.Rule.Allow...) + rv.Deny = append([]string(nil), p.Rule.Deny...) + rv.Identities = append([]string(nil), p.Rule.Identities...) + entry.Rule = &rv + } + entry.Observers = append([]HookEntry(nil), p.Observers...) + entry.Wrappers = append([]HookEntry(nil), p.Wrappers...) + entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...) + out.Plugins[i] = entry + } + return out +} diff --git a/internal/platform/inventory_test.go b/internal/platform/inventory_test.go new file mode 100644 index 000000000..a9d8d8b51 --- /dev/null +++ b/internal/platform/inventory_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform_test + +import ( + "context" + "testing" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +func TestBuildInventory_groupsByPluginName(t *testing.T) { + plugins := []internalplatform.PluginInventorySource{ + {Name: "a", Version: "1.0", Capabilities: platform.Capabilities{ + Restricts: true, FailurePolicy: platform.FailClosed, + }}, + {Name: "b", Version: "2.0"}, + } + + r := hook.NewRegistry() + obs := func(context.Context, platform.Invocation) {} + wrap := func(next platform.Handler) platform.Handler { return next } + lc := func(context.Context, *platform.LifecycleContext) error { return nil } + + r.AddObserver(hook.ObserverEntry{Name: "a.pre", When: platform.Before, Selector: platform.All(), Fn: obs}) + r.AddObserver(hook.ObserverEntry{Name: "a.post", When: platform.After, Selector: platform.All(), Fn: obs}) + r.AddObserver(hook.ObserverEntry{Name: "b.audit", When: platform.Before, Selector: platform.All(), Fn: obs}) + r.AddWrapper(hook.WrapperEntry{Name: "a.approval", Selector: platform.All(), Fn: wrap}) + r.AddLifecycle(hook.LifecycleEntry{Name: "a.boot", Event: platform.Startup, Fn: lc}) + r.AddLifecycle(hook.LifecycleEntry{Name: "b.bye", Event: platform.Shutdown, Fn: lc}) + + rules := []internalplatform.RuleInventorySource{ + {PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"}, + } + + inv := internalplatform.BuildInventory(plugins, r, rules) + + if got := len(inv.Plugins); got != 2 { + t.Fatalf("Plugins len = %d, want 2", got) + } + a := findPlugin(inv, "a") + b := findPlugin(inv, "b") + if a == nil || b == nil { + t.Fatalf("missing entries: a=%v b=%v", a, b) + } + + if got := len(a.Observers); got != 2 { + t.Errorf("a.Observers = %d, want 2", got) + } + if got := len(a.Wrappers); got != 1 { + t.Errorf("a.Wrappers = %d, want 1", got) + } + if got := len(a.Lifecycles); got != 1 { + t.Errorf("a.Lifecycles = %d, want 1", got) + } + if a.Rule == nil || a.Rule.Name != "a-rule" { + t.Errorf("a.Rule = %+v, want name a-rule", a.Rule) + } + if a.Capabilities.FailurePolicy != "FailClosed" { + t.Errorf("a.Capabilities.FailurePolicy = %q, want FailClosed", a.Capabilities.FailurePolicy) + } + + if got := len(b.Observers); got != 1 { + t.Errorf("b.Observers = %d, want 1 (only b.audit)", got) + } + if b.Rule != nil { + t.Errorf("b.Rule = %+v, want nil (b did not call Restrict)", b.Rule) + } + if b.Capabilities.FailurePolicy != "FailOpen" { + t.Errorf("b.Capabilities.FailurePolicy = %q, want FailOpen (zero value)", b.Capabilities.FailurePolicy) + } +} + +func TestBuildInventory_empty(t *testing.T) { + inv := internalplatform.BuildInventory(nil, nil, nil) + if got := len(inv.Plugins); got != 0 { + t.Errorf("Plugins len = %d, want 0", got) + } +} + +func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry { + for i := range inv.Plugins { + if inv.Plugins[i].Name == name { + return &inv.Plugins[i] + } + } + return nil +} diff --git a/internal/platform/staging.go b/internal/platform/staging.go new file mode 100644 index 000000000..1b0b7668a --- /dev/null +++ b/internal/platform/staging.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import ( + "fmt" + "regexp" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" +) + +// hookNamePattern is the grammar both Plugin.Name() and hookName must +// match -- design hard-constraint #9. The "." character is forbidden so +// the namespace join "{plugin}.{hook}" is unambiguous. +var hookNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// stagingRegistrar buffers every Registrar call so the platformhost can +// commit them atomically (or discard them all) once Install returns. +// +// All validation happens here at staging time -- bad hookName, nil +// handler, duplicate names, etc. produce typed errors that surface in +// validateSelf and are translated into PluginInstallError by the host +// loop. +type stagingRegistrar struct { + pluginName string + + stagedObservers []hook.ObserverEntry + stagedWrappers []hook.WrapperEntry + stagedLifecycles []hook.LifecycleEntry + + // rule is the staged Restrict contribution, captured for the host + // to merge with the yaml side later. nil means the plugin did not + // call r.Restrict. + rule *platform.Rule + + // actuallyRestricted records whether r.Restrict was called at all. + // Even a Restrict(nil) flips this to true so the + // Restricts-vs-actual consistency check can detect the call. + actuallyRestricted bool + + // seenHookNames detects duplicate hookName within this plugin's + // Install call. + seenHookNames map[string]bool + + // stagingErrs accumulates per-call validation errors. A single + // Install can violate the grammar multiple times; collecting all + // of them lets diagnostic output show the full picture. + stagingErrs []stagingErr +} + +// stagingErr is the per-call buffered validation failure. +type stagingErr struct { + reasonCode string + message string +} + +func newStagingRegistrar(pluginName string) *stagingRegistrar { + return &stagingRegistrar{ + pluginName: pluginName, + seenHookNames: map[string]bool{}, + } +} + +// --- Registrar interface --- + +func (r *stagingRegistrar) Observe(when platform.When, name string, sel platform.Selector, fn platform.Observer) { + if !r.validateName(name) { + return + } + if !r.validateNonNilSelector(name, sel) { + return + } + if fn == nil { + r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("observe %q: handler is nil", name)) + return + } + if !isValidWhen(when) { + r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("observe %q: invalid When value %d", name, when)) + return + } + r.stagedObservers = append(r.stagedObservers, hook.ObserverEntry{ + Name: r.namespaced(name), + When: when, + Selector: sel, + Fn: fn, + }) +} + +func (r *stagingRegistrar) Wrap(name string, sel platform.Selector, w platform.Wrapper) { + if !r.validateName(name) { + return + } + if !r.validateNonNilSelector(name, sel) { + return + } + if w == nil { + r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("wrap %q: handler is nil", name)) + return + } + r.stagedWrappers = append(r.stagedWrappers, hook.WrapperEntry{ + Name: r.namespaced(name), + Selector: sel, + Fn: w, + }) +} + +func (r *stagingRegistrar) On(event platform.LifecycleEvent, name string, fn platform.LifecycleHandler) { + if !r.validateName(name) { + return + } + if fn == nil { + r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("on %q: handler is nil", name)) + return + } + if !isValidLifecycleEvent(event) { + r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("on %q: invalid LifecycleEvent value %d", name, event)) + return + } + r.stagedLifecycles = append(r.stagedLifecycles, hook.LifecycleEntry{ + Name: r.namespaced(name), + Event: event, + Fn: fn, + }) +} + +func (r *stagingRegistrar) Restrict(rule *platform.Rule) { + if r.actuallyRestricted { + r.bufferErr(ReasonDoubleRestrict, "Restrict called more than once") + return + } + r.actuallyRestricted = true + if rule == nil { + r.bufferErr(ReasonInvalidRule, "Restrict(nil)") + return + } + // Defensive clone: retaining the caller's *Rule directly would let + // the plugin mutate Allow/Deny/Identities (or even the whole rule) + // after Install returns, bypassing the validation we run on the + // stored copy in validateSelf. Take an independent snapshot of + // every slice field so the post-validation rule is frozen. + cp := *rule + cp.Allow = append([]string(nil), rule.Allow...) + cp.Deny = append([]string(nil), rule.Deny...) + cp.Identities = append([]platform.Identity(nil), rule.Identities...) + r.rule = &cp +} + +// --- helpers --- + +func (r *stagingRegistrar) namespaced(name string) string { + return r.pluginName + "." + name +} + +func (r *stagingRegistrar) validateName(name string) bool { + if !hookNamePattern.MatchString(name) { + r.bufferErr(ReasonInvalidHookName, fmt.Sprintf("hookName %q must match ^[a-z0-9][a-z0-9-]*$", name)) + return false + } + if r.seenHookNames[name] { + r.bufferErr(ReasonDuplicateHookName, fmt.Sprintf("hookName %q registered twice in same plugin", name)) + return false + } + r.seenHookNames[name] = true + return true +} + +func (r *stagingRegistrar) validateNonNilSelector(name string, sel platform.Selector) bool { + if sel == nil { + r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("hook %q: selector is nil", name)) + return false + } + return true +} + +func (r *stagingRegistrar) bufferErr(reasonCode, message string) { + r.stagingErrs = append(r.stagingErrs, stagingErr{ + reasonCode: reasonCode, + message: message, + }) +} + +// validateSelf runs after Install returns. It checks: +// +// - any buffered staging error -> abort +// - Restricts declared but Install did not call r.Restrict -> abort +// - Restricts NOT declared but Install did call r.Restrict -> abort +// +// Returns the first PluginInstallError encountered (callers can use +// errors.As to inspect it). Nil means staging is clean. +func (r *stagingRegistrar) validateSelf(caps platform.Capabilities) error { + if len(r.stagingErrs) > 0 { + first := r.stagingErrs[0] + return &PluginInstallError{ + PluginName: r.pluginName, + ReasonCode: first.reasonCode, + Reason: first.message, + } + } + if caps.Restricts && !r.actuallyRestricted { + return &PluginInstallError{ + PluginName: r.pluginName, + ReasonCode: ReasonRestrictsMismatch, + Reason: "Capabilities.Restricts=true but Install did not call r.Restrict", + } + } + if !caps.Restricts && r.actuallyRestricted { + return &PluginInstallError{ + PluginName: r.pluginName, + ReasonCode: ReasonRestrictsMismatch, + Reason: "Capabilities.Restricts=false but Install called r.Restrict", + } + } + return nil +} + +func isValidWhen(w platform.When) bool { + return w == platform.Before || w == platform.After +} + +func isValidLifecycleEvent(e platform.LifecycleEvent) bool { + switch e { + case platform.Startup, platform.Shutdown: + return true + } + return false +} diff --git a/internal/platform/version.go b/internal/platform/version.go new file mode 100644 index 000000000..9cdc05fb7 --- /dev/null +++ b/internal/platform/version.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import ( + "fmt" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/build" +) + +// currentCLIVersion returns the running binary's version, redirectable +// from tests via SetCurrentCLIVersionForTesting. Production reads from +// internal/build.Version, which is set by -ldflags at release time. +var currentCLIVersion = func() string { return build.Version } + +// SetCurrentCLIVersionForTesting overrides the version reported to the +// RequiredCLIVersion check. Returns a restore function tests must defer. +func SetCurrentCLIVersionForTesting(v string) func() { + old := currentCLIVersion + currentCLIVersion = func() string { return v } + return func() { currentCLIVersion = old } +} + +// satisfiesRequiredCLIVersion reports whether buildVersion meets the +// constraint declared by Plugin.Capabilities().RequiredCLIVersion. +// +// Supported constraint forms (single comparator, no compound): +// +// "" - no requirement (always satisfied) +// "1.2.3" - exact match (equivalent to "=1.2.3") +// "=1.2.3" - exact match +// ">=1.2" - buildVersion >= 1.2 (missing patch -> 0) +// ">1.2" - strict greater than +// "<=1.2" - less than or equal +// "<1.2" - strict less than +// +// Development builds (buildVersion == "DEV" or "") always satisfy the +// constraint; the check is meaningful only for tagged releases. +// +// Returns false and an error when constraint is malformed -- callers +// should treat parse errors as fail-closed so an authoring mistake in +// the plugin does not silently load against the wrong CLI version. +// +// **Order of checks**: constraint syntax is validated FIRST, before the +// DEV-build short-circuit. A malformed constraint is a plugin authoring +// bug; we surface it even on DEV builds so the typo can be caught +// during plugin development instead of waiting for the first tagged +// release to expose it. +func satisfiesRequiredCLIVersion(buildVersion, constraint string) (bool, error) { + constraint = strings.TrimSpace(constraint) + if constraint == "" { + return true, nil + } + + op, rhs := splitConstraint(constraint) + rv, err := parseSemverPrefix(rhs) + if err != nil { + return false, fmt.Errorf("invalid RequiredCLIVersion %q: %w", constraint, err) + } + + if buildVersion == "" || buildVersion == "DEV" { + return true, nil + } + + bv, err := parseSemverPrefix(buildVersion) + if err != nil { + // Build version is unparseable -- treat as DEV so an exotic + // build tag doesn't lock plugins out. + return true, nil //nolint:nilerr // intentional fail-open for unparseable buildVersion + } + cmp := compareSemver(bv, rv) + switch op { + case "=", "": + return cmp == 0, nil + case ">=": + return cmp >= 0, nil + case ">": + return cmp > 0, nil + case "<=": + return cmp <= 0, nil + case "<": + return cmp < 0, nil + default: + return false, fmt.Errorf("invalid RequiredCLIVersion %q: unknown operator %q", constraint, op) + } +} + +// splitConstraint extracts the leading comparator (if any) from a +// constraint string. The operator is one of "", "=", ">=", ">", "<=", "<". +func splitConstraint(s string) (op, rest string) { + switch { + case strings.HasPrefix(s, ">="): + return ">=", strings.TrimSpace(s[2:]) + case strings.HasPrefix(s, "<="): + return "<=", strings.TrimSpace(s[2:]) + case strings.HasPrefix(s, ">"): + return ">", strings.TrimSpace(s[1:]) + case strings.HasPrefix(s, "<"): + return "<", strings.TrimSpace(s[1:]) + case strings.HasPrefix(s, "="): + return "=", strings.TrimSpace(s[1:]) + default: + return "", s + } +} + +// parseSemverPrefix parses MAJOR[.MINOR[.PATCH]] and drops any pre-release / +// build suffix. Missing minor / patch default to 0. Accepts a leading "v". +func parseSemverPrefix(s string) (parts [3]int, err error) { + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + if s == "" { + return parts, fmt.Errorf("empty version") + } + // Trim pre-release/build suffix at first '-' or '+'. + for i, c := range s { + if c == '-' || c == '+' { + s = s[:i] + break + } + } + fields := strings.Split(s, ".") + // Reject `1.2.3.4` and longer instead of silently truncating — + // truncation hides the typo and lets a malformed RequiredCLIVersion + // pass validation while the comparator below operates on the wrong + // components. Build-version parsing has its own fail-open guard + // upstream (see satisfiesRequiredCLIVersion comment about exotic + // build tags), so it stays compatible. + if len(fields) > 3 { + return [3]int{}, fmt.Errorf("version %q has more than three numeric components", s) + } + for i, f := range fields { + n, err := strconv.Atoi(strings.TrimSpace(f)) + if err != nil || n < 0 { + return [3]int{}, fmt.Errorf("non-numeric component %q in version %q", f, s) + } + parts[i] = n + } + return parts, nil +} + +func compareSemver(a, b [3]int) int { + for i := 0; i < 3; i++ { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + return 0 +} diff --git a/internal/platform/version_test.go b/internal/platform/version_test.go new file mode 100644 index 000000000..fec37bf0b --- /dev/null +++ b/internal/platform/version_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +func TestSatisfiesRequiredCLIVersion_constraints(t *testing.T) { + cases := []struct { + name string + build string + constraint string + want bool + wantErr bool + }{ + {"empty constraint always satisfied", "1.0.0", "", true, false}, + {"DEV build always satisfied", "DEV", ">=99.0.0", true, false}, + {"empty build counts as DEV", "", ">=99.0.0", true, false}, + {"v prefix stripped", "v1.0.28", ">=1.0.0", true, false}, + {"exact match implicit operator", "1.0.0", "1.0.0", true, false}, + {"exact match explicit =", "1.0.0", "=1.0.0", true, false}, + {">= equal", "1.0.0", ">=1.0.0", true, false}, + {">= higher", "1.2.0", ">=1.0.0", true, false}, + {">= lower fails", "1.0.0", ">=2.0.0", false, false}, + {"> strict higher", "1.0.1", ">1.0.0", true, false}, + {"> equal fails", "1.0.0", ">1.0.0", false, false}, + {"<= equal", "1.0.0", "<=1.0.0", true, false}, + {"<= higher fails", "2.0.0", "<=1.0.0", false, false}, + {"< strict lower", "0.9.0", "<1.0.0", true, false}, + {"missing patch defaults to 0", "1.0", ">=1.0.0", true, false}, + {"constraint with pre-release suffix", "1.0.0-rc1", ">=1.0.0", true, false}, + {"malformed constraint returns error", "1.0.0", ">=abc", false, true}, + {"malformed constraint errors on DEV too", "DEV", ">=abc", false, true}, + {"malformed constraint errors on empty build", "", ">=zzz", false, true}, + {"unparseable build version treated as DEV", "abc", ">=1.0.0", true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := satisfiesRequiredCLIVersion(tc.build, tc.constraint) + if tc.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +// A plugin whose RequiredCLIVersion exceeds the running build must +// abort install with reason_code capability_unmet. The plugin's +// FailurePolicy then decides whether the abort bubbles up. +func TestInstallOne_RequiredCLIVersion_UnmetFailClosedAborts(t *testing.T) { + restore := SetCurrentCLIVersionForTesting("1.0.0") + t.Cleanup(restore) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + platform.Register(&capVersionPlugin{ + name: "needs-future", + requirement: ">=99.0.0", + fail: platform.FailClosed, + }) + + _, err := InstallAll(platform.RegisteredPlugins(), nil) + if err == nil { + t.Fatal("expected FailClosed install error, got nil") + } + var pi *PluginInstallError + if !errors.As(err, &pi) { + t.Fatalf("expected *PluginInstallError, got %T", err) + } + if pi.ReasonCode != ReasonCapabilityUnmet { + t.Errorf("reason_code = %q, want %q", pi.ReasonCode, ReasonCapabilityUnmet) + } +} + +// FailOpen plugin with unmet RequiredCLIVersion is skipped (warning), +// other plugins still install. +func TestInstallOne_RequiredCLIVersion_UnmetFailOpenSkips(t *testing.T) { + restore := SetCurrentCLIVersionForTesting("1.0.0") + t.Cleanup(restore) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + platform.Register(&capVersionPlugin{ + name: "future-failopen", + requirement: ">=99.0.0", + fail: platform.FailOpen, + }) + + result, err := InstallAll(platform.RegisteredPlugins(), nil) + if err != nil { + t.Fatalf("FailOpen unmet must not bubble up, got: %v", err) + } + if result.Registry == nil { + t.Errorf("Registry should be non-nil even after FailOpen skip") + } +} + +// A plugin authoring error in RequiredCLIVersion (parse failure) must +// abort installation UNCONDITIONALLY. Even FailOpen cannot mask a +// typo in the constraint string -- the plugin author asked the host +// to do something it cannot parse, and silently skipping would hide +// the bug from CI. +// +// Implementation: parse errors return ReasonInvalidCapability, which +// isUntrustedConfigError lists alongside restricts_mismatch so +// InstallAll's switch treats it as a hard abort. +func TestInstallOne_RequiredCLIVersion_MalformedAbortsRegardlessOfFailurePolicy(t *testing.T) { + restore := SetCurrentCLIVersionForTesting("1.0.0") + t.Cleanup(restore) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + // FailOpen + malformed constraint: still aborts. + platform.Register(&capVersionPlugin{ + name: "typo", + requirement: ">=abc", + fail: platform.FailOpen, + }) + + _, err := InstallAll(platform.RegisteredPlugins(), nil) + if err == nil { + t.Fatal("expected malformed constraint to abort even FailOpen, got nil") + } + var pi *PluginInstallError + if !errors.As(err, &pi) { + t.Fatalf("expected *PluginInstallError, got %T", err) + } + if pi.ReasonCode != ReasonInvalidCapability { + t.Errorf("reason_code = %q, want %q", pi.ReasonCode, ReasonInvalidCapability) + } +} + +// A plugin whose RequiredCLIVersion is satisfied installs normally. +func TestInstallOne_RequiredCLIVersion_SatisfiedInstalls(t *testing.T) { + restore := SetCurrentCLIVersionForTesting("1.5.0") + t.Cleanup(restore) + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + platform.Register(&capVersionPlugin{ + name: "ok", + requirement: ">=1.0.0", + fail: platform.FailClosed, + }) + if _, err := InstallAll(platform.RegisteredPlugins(), nil); err != nil { + t.Errorf("expected install success, got %v", err) + } +} + +type capVersionPlugin struct { + name string + requirement string + fail platform.FailurePolicy +} + +func (p *capVersionPlugin) Name() string { return p.name } +func (p *capVersionPlugin) Version() string { return "0.0.1" } +func (p *capVersionPlugin) Capabilities() platform.Capabilities { + return platform.Capabilities{ + RequiredCLIVersion: p.requirement, + FailurePolicy: p.fail, + } +} +func (p *capVersionPlugin) Install(platform.Registrar) error { return nil } diff --git a/shortcuts/register.go b/shortcuts/register.go index f2e9f85dd..439870787 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -9,6 +9,7 @@ import ( "github.com/larksuite/cli/shortcuts/okr" "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/base" @@ -92,6 +93,18 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f } program.AddCommand(svc) } + // Tag the service group with its domain so platform.ByDomain + // and Rule.Allow path-globs work without each leaf shortcut + // having to declare the domain itself: cmdmeta.Domain walks up + // the parent chain and stops at the first annotated ancestor + // (this command). + // + // Done OUTSIDE the create branch so the tag is still applied + // when the service command was pre-created by cmd/service + // (OpenAPI auto-registration adds im, drive, calendar, etc. + // before shortcuts run). Without this, only pure-shortcut + // services like `docs` would get tagged. + cmdmeta.SetDomain(svc, service) if service == "docs" { doc.ConfigureServiceHelp(svc) } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 87dddb1fe..3d791759f 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -98,6 +99,37 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) { } } +// Service-level cobra commands created by RegisterShortcuts must carry +// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain) +// and Rule.Allow path-globs can resolve a command's business domain. +// The annotation is set on the parent; cmdmeta.Domain walks up the +// parent chain so every leaf shortcut inherits without extra tagging. +func TestRegisterShortcutsTagsServiceDomain(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + for _, svc := range []string{"im", "docs", "drive", "calendar", "base"} { + group, _, err := program.Find([]string{svc}) + if err != nil || group == nil { + t.Errorf("service %q not mounted", svc) + continue + } + if got := cmdmeta.Domain(group); got != svc { + t.Errorf("service %q domain = %q, want %q", svc, got, svc) + } + } + + // Inheritance: a leaf shortcut under a service must also resolve + // to the parent's domain via cmdmeta.Domain's parent-chain walk. + leaf, _, err := program.Find([]string{"im", "+messages-send"}) + if err != nil || leaf == nil { + t.Fatalf("expected im/+messages-send to be mounted") + } + if got := cmdmeta.Domain(leaf); got != "im" { + t.Errorf("leaf domain via parent inheritance = %q, want %q", got, "im") + } +} + func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) { program := &cobra.Command{Use: "root"} RegisterShortcuts(program, newRegisterTestFactory(t)) From 241952459dd4a54e7d6f3c8896d1ae8ceefff660 Mon Sep 17 00:00:00 2001 From: wangweiming-01 Date: Mon, 18 May 2026 16:44:10 +0800 Subject: [PATCH 12/61] feat: add drive version shortcut (#841) Change-Id: I87bb32c86e3c3362f541ccc6320c656eb795ec9b --- shortcuts/common/download_path.go | 125 ++++ shortcuts/common/download_path_test.go | 115 ++++ shortcuts/drive/drive_version.go | 454 +++++++++++++++ shortcuts/drive/drive_version_test.go | 546 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 4 + shortcuts/drive/shortcuts_test.go | 4 + skills/lark-drive/SKILL.md | 5 + .../references/lark-drive-version-delete.md | 38 ++ .../references/lark-drive-version-get.md | 71 +++ .../references/lark-drive-version-history.md | 73 +++ .../references/lark-drive-version-revert.md | 35 ++ .../drive/drive_version_dryrun_test.go | 229 ++++++++ .../drive/drive_version_workflow_test.go | 234 ++++++++ 13 files changed, 1933 insertions(+) create mode 100644 shortcuts/common/download_path.go create mode 100644 shortcuts/common/download_path_test.go create mode 100644 shortcuts/drive/drive_version.go create mode 100644 shortcuts/drive/drive_version_test.go create mode 100644 skills/lark-drive/references/lark-drive-version-delete.md create mode 100644 skills/lark-drive/references/lark-drive-version-get.md create mode 100644 skills/lark-drive/references/lark-drive-version-history.md create mode 100644 skills/lark-drive/references/lark-drive-version-revert.md create mode 100644 tests/cli_e2e/drive/drive_version_dryrun_test.go create mode 100644 tests/cli_e2e/drive/drive_version_workflow_test.go diff --git a/shortcuts/common/download_path.go b/shortcuts/common/download_path.go new file mode 100644 index 000000000..24059f8d0 --- /dev/null +++ b/shortcuts/common/download_path.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "mime" + "net/http" + "path" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// DownloadExtensionResolution describes how a file extension was inferred. +type DownloadExtensionResolution struct { + Ext string + Source string + Detail string +} + +var downloadMimeToExt = map[string]string{ + "application/msword": ".doc", + "application/pdf": ".pdf", + "application/vnd.ms-excel": ".xls", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/xml": ".xml", + "application/zip": ".zip", + "image/bmp": ".bmp", + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/svg+xml": ".svg", + "image/webp": ".webp", + "text/csv": ".csv", + "text/html": ".html", + "text/plain": ".txt", + "text/xml": ".xml", + "video/mp4": ".mp4", +} + +// ResolveDownloadFileName returns a sanitized filename from Content-Disposition, +// falling back to the caller-provided name when the header is absent or invalid. +func ResolveDownloadFileName(header http.Header, fallback string) string { + name := strings.TrimSpace(larkcore.FileNameByHeader(header)) + if name == "" { + name = fallback + } + name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/") + name = path.Base(name) + if name == "" || name == "." || name == ".." { + return fallback + } + return name +} + +// AutoAppendDownloadExtension appends an inferred file extension when the +// target path has no explicit suffix. If no extension can be inferred, the +// original basename is preserved without adding a synthetic fallback suffix. +func AutoAppendDownloadExtension(outputPath string, header http.Header, fallbackExt string) (string, *DownloadExtensionResolution) { + if hasExplicitDownloadExtension(outputPath) { + return outputPath, nil + } + normalizedPath := outputPath + if filepath.Ext(outputPath) == "." { + normalizedPath = strings.TrimSuffix(outputPath, ".") + } + if resolution := downloadExtensionByContentType(header.Get("Content-Type")); resolution != nil { + return normalizedPath + resolution.Ext, resolution + } + if resolution := downloadExtensionByContentDisposition(header); resolution != nil { + return normalizedPath + resolution.Ext, resolution + } + if fallbackExt != "" { + return normalizedPath + fallbackExt, &DownloadExtensionResolution{ + Ext: fallbackExt, + Source: "fallback", + Detail: "default fallback", + } + } + return normalizedPath, nil +} + +func hasExplicitDownloadExtension(path string) bool { + ext := filepath.Ext(path) + return ext != "" && ext != "." +} + +func downloadExtensionByContentType(contentType string) *DownloadExtensionResolution { + if contentType == "" { + return nil + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0]) + } + if ext, ok := downloadMimeToExt[strings.ToLower(mediaType)]; ok { + return &DownloadExtensionResolution{ + Ext: ext, + Source: "Content-Type", + Detail: contentType, + } + } + return nil +} + +func downloadExtensionByContentDisposition(header http.Header) *DownloadExtensionResolution { + filename := strings.TrimSpace(larkcore.FileNameByHeader(header)) + if filename == "" { + return nil + } + ext := filepath.Ext(filename) + if ext == "" || ext == "." { + return nil + } + return &DownloadExtensionResolution{ + Ext: ext, + Source: "Content-Disposition", + Detail: filename, + } +} diff --git a/shortcuts/common/download_path_test.go b/shortcuts/common/download_path_test.go new file mode 100644 index 000000000..100eb9ccd --- /dev/null +++ b/shortcuts/common/download_path_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "net/http" + "testing" +) + +func TestResolveDownloadFileName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + header http.Header + fallback string + want string + }{ + { + name: "content disposition filename wins", + header: http.Header{ + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + fallback: "boxcn123", + want: "report-v7.md", + }, + { + name: "path traversal in header is stripped", + header: http.Header{ + "Content-Disposition": []string{`attachment; filename="../nested/report-v7.md"`}, + }, + fallback: "boxcn123", + want: "report-v7.md", + }, + { + name: "fallback when header missing", + header: http.Header{}, + fallback: "boxcn123", + want: "boxcn123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := ResolveDownloadFileName(tt.header, tt.fallback); got != tt.want { + t.Fatalf("ResolveDownloadFileName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAutoAppendDownloadExtension(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + header http.Header + want string + }{ + { + name: "explicit extension is preserved", + path: "artifact.bin", + header: http.Header{ + "Content-Type": []string{"text/csv; charset=utf-8"}, + }, + want: "artifact.bin", + }, + { + name: "appends extension from content type", + path: "artifact", + header: http.Header{ + "Content-Type": []string{"text/csv; charset=utf-8"}, + }, + want: "artifact.csv", + }, + { + name: "appends extension from content disposition when content type is generic", + path: "artifact", + header: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + want: "artifact.md", + }, + { + name: "trailing dot is normalized before append", + path: "artifact.", + header: http.Header{ + "Content-Type": []string{"text/plain; charset=utf-8"}, + }, + want: "artifact.txt", + }, + { + name: "unknown type keeps suffixless path", + path: "artifact.", + header: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + }, + want: "artifact", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, _ := AutoAppendDownloadExtension(tt.path, tt.header, "") + if got != tt.want { + t.Fatalf("AutoAppendDownloadExtension() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/shortcuts/drive/drive_version.go b/shortcuts/drive/drive_version.go new file mode 100644 index 000000000..bcf3fcf0c --- /dev/null +++ b/shortcuts/drive/drive_version.go @@ -0,0 +1,454 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "io" + "math" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var driveVersionNumberRe = regexp.MustCompile(`^\d{1,19}$`) + +type driveVersionHistorySpec struct { + FileToken string + Limit int + Cursor string +} + +func validateDriveNumericValue(value, flagName, valueLabel string) error { + value = strings.TrimSpace(value) + if value == "" { + return output.ErrValidation("%s cannot be empty", flagName) + } + if !driveVersionNumberRe.MatchString(value) { + return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel) + } + return nil +} + +func validateDriveVersionValue(value, flagName string) error { + return validateDriveNumericValue(value, flagName, "version string") +} + +func validateDriveCursorValue(value, flagName string) error { + return validateDriveNumericValue(value, flagName, "pagination cursor") +} + +func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + if spec.Limit < 1 || spec.Limit > 200 { + return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit) + } + if spec.Cursor != "" { + if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil { + return err + } + } + return nil +} + +func driveVersionHistoryParams(spec driveVersionHistorySpec) map[string]interface{} { + params := map[string]interface{}{ + "only_tag": true, + "page_size": spec.Limit, + } + if spec.Cursor != "" { + params["last_edit_time"] = spec.Cursor + } + return params +} + +func driveVersionActionTypeLabel(raw int) string { + switch raw { + case 1: + return "upload" + case 2: + return "rename" + case 3: + return "delete_version" + case 4: + return "revert" + default: + return fmt.Sprintf("type_%d", raw) + } +} + +func driveVersionFieldString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if s := common.GetString(m, key); s != "" { + return s + } + f, ok := util.ToFloat64(m[key]) + if !ok || math.IsInf(f, 0) || math.IsNaN(f) { + return "" + } + if math.Trunc(f) == f { + return strconv.FormatInt(int64(f), 10) + } + return strconv.FormatFloat(f, 'f', -1, 64) +} + +func transformDriveVersionHistory(items []interface{}) []map[string]interface{} { + versions := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + version := common.GetString(m, "version") + if version == "" { + continue + } + versions = append(versions, map[string]interface{}{ + "version": version, + "name": common.GetString(m, "name"), + "edited_at": driveVersionFieldString(m, "edit_time"), + "edited_by": common.GetString(m, "edit_user_id"), + "size_bytes": int64(common.GetFloat(m, "size")), + "action_type": driveVersionActionTypeLabel(int(common.GetFloat(m, "type"))), + "is_deleted": common.GetBool(m, "is_deleted"), + "tag": int(common.GetFloat(m, "tag")), + }) + } + return versions +} + +func nextDriveVersionCursor(items []interface{}, hasMore bool) string { + if !hasMore || len(items) == 0 { + return "" + } + last, _ := items[len(items)-1].(map[string]interface{}) + return driveVersionFieldString(last, "edit_time") +} + +var DriveVersionHistory = common.Shortcut{ + Service: "drive", + Command: "+version-history", + Description: "List the version history of a Drive file", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "limit", Desc: "max versions to return (1-200)", Type: "int", Default: "20"}, + {Name: "cursor", Desc: "pagination cursor from the previous page's next_cursor"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionHistorySpec(driveVersionHistorySpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Limit: runtime.Int("limit"), + Cursor: strings.TrimSpace(runtime.Str("cursor")), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionHistorySpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Limit: runtime.Int("limit"), + Cursor: strings.TrimSpace(runtime.Str("cursor")), + } + return common.NewDryRunAPI(). + Desc("Query version history with only_tag=true and optional pagination cursor"). + GET("/open-apis/drive/v1/files/:file_token/history"). + Set("file_token", spec.FileToken). + Params(driveVersionHistoryParams(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionHistorySpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Limit: runtime.Int("limit"), + Cursor: strings.TrimSpace(runtime.Str("cursor")), + } + + data, err := runtime.CallAPI( + http.MethodGet, + fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)), + driveVersionHistoryParams(spec), + nil, + ) + if err != nil { + return err + } + + items := common.GetSlice(data, "items") + hasMore := common.GetBool(data, "has_more") + out := map[string]interface{}{ + "versions": transformDriveVersionHistory(items), + "has_more": hasMore, + } + if nextCursor := nextDriveVersionCursor(items, hasMore); nextCursor != "" { + out["next_cursor"] = nextCursor + } + + runtime.OutFormat(out, nil, nil) + return nil + }, +} + +type driveVersionGetSpec struct { + FileToken string + Version string + Output string + Overwrite bool +} + +func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + if err := validateDriveVersionValue(spec.Version, "--version"); err != nil { + return err + } + if spec.Output == "" { + return nil + } + if _, err := validate.SafeOutputPath(spec.Output); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + return nil +} + +func driveVersionGetOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool { + if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") { + return true + } + info, err := runtime.FileIO().Stat(outputPath) + return err == nil && info.IsDir() +} + +func prettyPrintDriveVersionSavedFile(w io.Writer, data map[string]interface{}) { + fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token")) + fmt.Fprintf(w, "version: %s\n", common.GetString(data, "version")) + fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name")) + fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path")) + fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes"))) +} + +var DriveVersionGet = common.Shortcut{ + Service: "drive", + Command: "+version-get", + Description: "Download a specific version of a Drive file", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version from drive +version-history (not tag)", Required: true}, + {Name: "output", Desc: "local save path or directory; omit to save into the current directory using the server filename"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionGetSpec(runtime, driveVersionGetSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + Output: strings.TrimSpace(runtime.Str("output")), + Overwrite: runtime.Bool("overwrite"), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionGetSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + Output: strings.TrimSpace(runtime.Str("output")), + } + outputPath := spec.Output + if outputPath == "" { + outputPath = "." + } + return common.NewDryRunAPI(). + Desc("Download a specific file version; when --output is omitted the CLI saves into the current directory using the server filename"). + GET("/open-apis/drive/v1/files/:file_token/download"). + Set("file_token", spec.FileToken). + Set("output", outputPath). + Params(map[string]interface{}{"version": spec.Version}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionGetSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + Output: strings.TrimSpace(runtime.Str("output")), + Overwrite: runtime.Bool("overwrite"), + } + + resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(spec.FileToken)), + QueryParams: larkcore.QueryParams{ + "version": []string{spec.Version}, + }, + }) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + defer resp.Body.Close() + + fileName := common.ResolveDownloadFileName(resp.Header, spec.FileToken) + fileName, _ = common.AutoAppendDownloadExtension(fileName, resp.Header, "") + outputPath := spec.Output + if outputPath == "" { + outputPath = "." + } + if driveVersionGetOutputIsDirectory(runtime, outputPath) { + outputPath = filepath.Join(outputPath, fileName) + } else { + outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "") + } + if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { + return output.ErrValidation("unsafe output path: %s", resolveErr) + } + if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite { + return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + } + + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return common.WrapSaveErrorByCategory(err, "io") + } + + savedPath, _ := runtime.ResolveSavePath(outputPath) + if savedPath == "" { + savedPath = outputPath + } + out := map[string]interface{}{ + "file_token": spec.FileToken, + "version": spec.Version, + "file_name": filepath.Base(outputPath), + "saved_path": savedPath, + "size_bytes": result.Size(), + } + runtime.OutFormat(out, nil, func(w io.Writer) { + prettyPrintDriveVersionSavedFile(w, out) + }) + return nil + }, +} + +type driveVersionMutationSpec struct { + FileToken string + Version string +} + +func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + return validateDriveVersionValue(spec.Version, "--version") +} + +var DriveVersionRevert = common.Shortcut{ + Service: "drive", + Command: "+version-revert", + Description: "Revert a Drive file to a specific historical version", + Risk: "write", + Scopes: []string{"drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version from drive +version-history to revert to (not tag)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionMutationSpec(driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + return common.NewDryRunAPI(). + Desc("Revert the current file to a specified historical version"). + POST("/open-apis/drive/v1/files/:file_token/revert"). + Set("file_token", spec.FileToken). + Body(map[string]interface{}{"version": spec.Version}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + if _, err := runtime.CallAPI( + http.MethodPost, + fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)), + nil, + map[string]interface{}{"version": spec.Version}, + ); err != nil { + return err + } + + runtime.Out(map[string]interface{}{}, nil) + return nil + }, +} + +var DriveVersionDelete = common.Shortcut{ + Service: "drive", + Command: "+version-delete", + Description: "Delete a specific historical version of a Drive file", + Risk: "high-risk-write", + Scopes: []string{"drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version from drive +version-history to delete (not tag)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionMutationSpec(driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + return common.NewDryRunAPI(). + Desc("Permanently delete a historical file version"). + POST("/open-apis/drive/v1/files/:file_token/version_del"). + Set("file_token", spec.FileToken). + Body(map[string]interface{}{"version": spec.Version}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + if _, err := runtime.CallAPI( + http.MethodPost, + fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)), + nil, + map[string]interface{}{"version": spec.Version}, + ); err != nil { + return err + } + + runtime.Out(map[string]interface{}{}, nil) + return nil + }, +} diff --git a/shortcuts/drive/drive_version_test.go b/shortcuts/drive/drive_version_test.go new file mode 100644 index 000000000..309243ad4 --- /dev/null +++ b/shortcuts/drive/drive_version_test.go @@ -0,0 +1,546 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestValidateDriveVersionHistorySpec(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec driveVersionHistorySpec + wantErr string + }{ + { + name: "ok", + spec: driveVersionHistorySpec{FileToken: "box123", Limit: 20, Cursor: "1777013761763"}, + }, + { + name: "bad limit", + spec: driveVersionHistorySpec{FileToken: "box123", Limit: 0}, + wantErr: "invalid --limit", + }, + { + name: "bad cursor", + spec: driveVersionHistorySpec{FileToken: "box123", Limit: 20, Cursor: "abc"}, + wantErr: "--cursor must be a numeric pagination cursor", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateDriveVersionHistorySpec(tt.spec) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestDriveVersionHistoryExecuteTransformsResponse(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_hist/history", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []map[string]interface{}{ + { + "version": "7633658129540910621", + "name": "report.md", + "edit_time": 1777013761763, + "edit_user_id": "ou_hist_1", + "size": "12345", + "type": 1, + "is_deleted": false, + "tag": 7, + }, + { + "version": "7633658129540910622", + "name": "report.md", + "edit_time": 1777013770000, + "edit_user_id": "ou_hist_2", + "size": "12346", + "type": 4, + "is_deleted": true, + "tag": 8, + }, + }, + "has_more": true, + }, + }, + }) + + err := mountAndRunDrive(t, DriveVersionHistory, []string{ + "+version-history", + "--file-token", "box_hist", + "--limit", "2", + "--cursor", "1777013000000", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope struct { + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + + if got := common.GetBool(envelope.Data, "has_more"); !got { + t.Fatalf("has_more = %v, want true", got) + } + if got := common.GetString(envelope.Data, "next_cursor"); got != "1777013770000" { + t.Fatalf("next_cursor = %q, want %q", got, "1777013770000") + } + + versions, _ := envelope.Data["versions"].([]interface{}) + if len(versions) != 2 { + t.Fatalf("len(versions) = %d, want 2", len(versions)) + } + first, _ := versions[0].(map[string]interface{}) + if got := common.GetString(first, "version"); got != "7633658129540910621" { + t.Fatalf("first.version = %q", got) + } + if got := common.GetString(first, "edited_at"); got != "1777013761763" { + t.Fatalf("first.edited_at = %q, want %q", got, "1777013761763") + } + if got := common.GetString(first, "action_type"); got != "upload" { + t.Fatalf("first.action_type = %q, want upload", got) + } + if got := common.GetBool(first, "is_deleted"); got { + t.Fatalf("first.is_deleted = %v, want false", got) + } + second, _ := versions[1].(map[string]interface{}) + if got := common.GetString(second, "action_type"); got != "revert" { + t.Fatalf("second.action_type = %q, want revert", got) + } + if got := common.GetBool(second, "is_deleted"); !got { + t.Fatalf("second.is_deleted = %v, want true", got) + } +} + +func TestDriveVersionGetWritesSpecificVersion(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "version.bin", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "version.bin")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "versioned-data" { + t.Fatalf("downloaded content = %q", string(data)) + } + if !strings.Contains(stdout.String(), `"version": "7633658129540910621"`) { + t.Fatalf("stdout missing version: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"saved_path":`) { + t.Fatalf("stdout missing saved_path: %s", stdout.String()) + } +} + +func TestDriveVersionGetSavesToCurrentDirectoryWhenOutputIsOmitted(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: http.Header{ + "Content-Type": []string{"text/plain; charset=utf-8"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "report-v7.md")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "# hello\n" { + t.Fatalf("downloaded content = %q", string(data)) + } + if !strings.Contains(stdout.String(), `"file_name": "report-v7.md"`) { + t.Fatalf("stdout missing file_name: %s", stdout.String()) + } + if strings.Contains(stdout.String(), `"content":`) { + t.Fatalf("stdout unexpectedly contains content payload: %s", stdout.String()) + } +} + +func TestDriveVersionGetRejectsExistingFileWithoutOverwrite(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("version.bin", []byte("existing"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "version.bin", + "--as", "bot", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "output file already exists") { + t.Fatalf("expected output exists error, got %v", err) + } +} + +func TestDriveVersionGetOverwritesExistingFileWhenRequested(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("version.bin", []byte("existing"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "version.bin", + "--overwrite", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "version.bin")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "versioned-data" { + t.Fatalf("downloaded content = %q", string(data)) + } +} + +func TestDriveVersionGetSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("downloads", 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "downloads", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join("downloads", "report-v7.md")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "versioned-data" { + t.Fatalf("downloaded content = %q", string(data)) + } +} + +func TestDriveVersionGetAppendsExtensionFromContentDispositionFilename(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "artifact", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "artifact.md")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "versioned-data" { + t.Fatalf("downloaded content = %q", string(data)) + } + if !strings.Contains(stdout.String(), `"file_name": "artifact.md"`) { + t.Fatalf("stdout missing local file_name: %s", stdout.String()) + } +} + +func TestDriveVersionRevertPostsVersionAndReturnsEmptyData(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + revertStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/box_rev/revert", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + } + reg.Register(revertStub) + + err := mountAndRunDrive(t, DriveVersionRevert, []string{ + "+version-revert", + "--file-token", "box_rev", + "--version", "7633658129540910621", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedJSONBody(t, revertStub) + if got := common.GetString(body, "version"); got != "7633658129540910621" { + t.Fatalf("body.version = %q, want 7633658129540910621", got) + } + if !strings.Contains(stdout.String(), `"data": {}`) { + t.Fatalf("stdout = %s, want empty data object", stdout.String()) + } +} + +func TestDriveVersionDeletePostsVersionAndReturnsEmptyData(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + deleteStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/box_del/version_del", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + } + reg.Register(deleteStub) + + err := mountAndRunDrive(t, DriveVersionDelete, []string{ + "+version-delete", + "--file-token", "box_del", + "--version", "7633658129540910621", + "--yes", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedJSONBody(t, deleteStub) + if got := common.GetString(body, "version"); got != "7633658129540910621" { + t.Fatalf("body.version = %q, want 7633658129540910621", got) + } + if !strings.Contains(stdout.String(), `"data": {}`) { + t.Fatalf("stdout = %s, want empty data object", stdout.String()) + } +} + +func TestDriveVersionRevertDoesNotAcceptYes(t *testing.T) { + t.Parallel() + + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, DriveVersionRevert, []string{ + "+version-revert", + "--file-token", "box_rev", + "--version", "7633658129540910621", + "--yes", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected unknown flag error, got nil") + } + if !strings.Contains(err.Error(), "unknown flag: --yes") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveVersionDeleteRequiresYes(t *testing.T) { + t.Parallel() + + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, DriveVersionDelete, []string{ + "+version-delete", + "--file-token", "box_del", + "--version", "7633658129540910621", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected confirmation error, got nil") + } + if !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveVersionShortcutsSupportUserDryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + args []string + }{ + { + name: "history", + shortcut: DriveVersionHistory, + args: []string{ + "+version-history", + "--file-token", "box_hist", + "--limit", "2", + "--cursor", "1777013000000", + "--as", "user", + "--dry-run", + }, + }, + { + name: "get", + shortcut: DriveVersionGet, + args: []string{ + "+version-get", + "--file-token", "box_get", + "--version", "7633658129540910621", + "--output", "version.bin", + "--as", "user", + "--dry-run", + }, + }, + { + name: "revert", + shortcut: DriveVersionRevert, + args: []string{ + "+version-revert", + "--file-token", "box_rev", + "--version", "7633658129540910621", + "--as", "user", + "--dry-run", + }, + }, + { + name: "delete", + shortcut: DriveVersionDelete, + args: []string{ + "+version-delete", + "--file-token", "box_del", + "--version", "7633658129540910621", + "--as", "user", + "--dry-run", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, tt.shortcut, tt.args, f, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index fcd3d805e..7edcbfd1d 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -16,6 +16,10 @@ func Shortcuts() []common.Shortcut { DriveExport, DriveExportDownload, DriveImport, + DriveVersionHistory, + DriveVersionGet, + DriveVersionRevert, + DriveVersionDelete, DriveMove, DriveDelete, DriveStatus, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 3116c0c5a..2d7a6911e 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -15,6 +15,10 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+create-folder", "+create-shortcut", "+download", + "+version-history", + "+version-get", + "+version-revert", + "+version-delete", "+add-comment", "+export", "+export-download", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index ad4c1fcb9..fe55ff61c 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -20,6 +20,7 @@ metadata: - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 - 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。 +- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。 - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。 - 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。 - 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token `;不要误切到 `wiki` 域命令。 @@ -249,6 +250,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming | | [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token | | [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) | +| [`+version-history`](references/lark-drive-version-history.md) | List historical versions of a file with only_tag=true and cursor-based pagination | +| [`+version-get`](references/lark-drive-version-get.md) | Download a specific historical version of a file | +| [`+version-revert`](references/lark-drive-version-revert.md) | Revert a file to a specific historical version | +| [`+version-delete`](references/lark-drive-version-delete.md) | Delete a specific historical version of a file | | [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive | | [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes | | [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside cwd. | diff --git a/skills/lark-drive/references/lark-drive-version-delete.md b/skills/lark-drive/references/lark-drive-version-delete.md new file mode 100644 index 000000000..58bf6eced --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-delete.md @@ -0,0 +1,38 @@ +# drive +version-delete + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +删除指定的历史版本。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-delete \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --yes \ + --as bot + +lark-cli drive +version-delete \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --yes \ + --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | `drive +version-history` 返回的长数字 `version` 字段,不是 `tag` | +| `--yes` | 是 | 确认执行高风险删除操作 | + +## 返回值 + +无额外业务字段,以命令成功 / 失败为准。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-version-get.md b/skills/lark-drive/references/lark-drive-version-get.md new file mode 100644 index 000000000..ec72e32fc --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-get.md @@ -0,0 +1,71 @@ +# drive +version-get + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +下载指定版本的文件内容。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as bot + +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as user + +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --output ./downloads/ \ + --as bot + +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --output ./artifact.bin \ + --overwrite \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | `drive +version-history` 返回的长数字 `version` 字段,不是 `tag` | +| `--output` | 否 | 本地保存路径或目录;省略时保存到当前目录,并优先使用服务端文件名 | +| `--overwrite` | 否 | 覆盖已存在的本地输出文件 | + +## 关键行为 + +- 省略 `--output` 时,CLI 保存到当前目录,并优先使用服务端文件名 +- `--output` 指向已存在目录,或以 `/` / `\\` 结尾时,CLI 会使用远端文件名保存 +- `--output` 是文件路径且没有后缀时,CLI 会像 `docs +media-download` 一样尝试从响应头推断后缀;推不出来就保持无后缀 +- 目标文件已存在时,只有显式传 `--overwrite` 才会覆盖 + +## 返回值 + +返回值: + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "file_token": "boxcnxxxxxxxx", + "version": "7633658129540910621", + "file_name": "artifact.bin", + "saved_path": "/abs/path/artifact.bin", + "size_bytes": 12345 + } +} +``` + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-version-history.md b/skills/lark-drive/references/lark-drive-version-history.md new file mode 100644 index 000000000..e1a229776 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-history.md @@ -0,0 +1,73 @@ +# drive +version-history + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出指定文件的历史版本快照。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --as bot + +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --as user + +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --limit 50 \ + --cursor 1777013761763 \ + --as bot + +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --dry-run \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--limit` | 否 | 返回条数上限,范围 `1-200`,默认 `20` | +| `--cursor` | 否 | 分页游标;取上一页返回的 `next_cursor` 回填 | + +## 关键行为 + +- shortcut 内部固定传 `only_tag=true` +- 返回 `has_more=true` 时,使用 `next_cursor` 继续翻页 +- `versions[].version` 是传给 `drive +version-get` / `+version-revert` / `+version-delete` 的长数字版本串;`tag` 只是展示序号,不能替代 `version` +- `versions[].is_deleted` 为布尔值,表示该历史版本是否已被删除 + +## 返回值 + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "versions": [ + { + "version": "7633658129540910621", + "name": "report.md", + "edited_at": "1777013761763", + "edited_by": "ou_xxx", + "size_bytes": "12345", + "action_type": "upload", + "is_deleted": false, + "tag": 7 + } + ], + "has_more": true, + "next_cursor": "1777013761763" + } +} +``` + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-version-revert.md b/skills/lark-drive/references/lark-drive-version-revert.md new file mode 100644 index 000000000..31a9e3078 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-revert.md @@ -0,0 +1,35 @@ +# drive +version-revert + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将文件回滚到指定历史版本。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-revert \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as bot + +lark-cli drive +version-revert \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | `drive +version-history` 返回的长数字 `version` 字段,不是 `tag` | + +## 返回值 + +无额外业务字段,以命令成功 / 失败为准。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/tests/cli_e2e/drive/drive_version_dryrun_test.go b/tests/cli_e2e/drive/drive_version_dryrun_test.go new file mode 100644 index 000000000..f386d101e --- /dev/null +++ b/tests/cli_e2e/drive/drive_version_dryrun_test.go @@ -0,0 +1,229 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDriveVersionHistoryDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-history", + "--file-token", "boxcnHistoryDryRun", + "--limit", "5", + "--cursor", "1777013761763", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnHistoryDryRun/history") + assert.Contains(t, output, `"only_tag": true`) + assert.Contains(t, output, `"page_size": 5`) + assert.Contains(t, output, `"last_edit_time": "1777013761763"`) +} + +func TestDriveVersionGetDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-get", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--output", "./artifact.bin", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download") + assert.Contains(t, output, `"version": "7633658129540910621"`) + assert.Contains(t, output, `"output": "./artifact.bin"`) +} + +func TestDriveVersionGetDryRunWithoutOutputUsesCurrentDirectory(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-get", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download") + assert.Contains(t, output, `"version": "7633658129540910621"`) + assert.Contains(t, output, `"output": "."`) +} + +func TestDriveVersionRevertDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-revert", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/revert") + assert.Contains(t, output, `"version": "7633658129540910621"`) +} + +func TestDriveVersionDeleteDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-delete", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/version_del") + assert.Contains(t, output, `"version": "7633658129540910621"`) +} + +func TestDriveVersionDryRunSupportsUser(t *testing.T) { + clie2e.SkipWithoutUserToken(t) + setDriveDryRunConfigEnv(t) + + tests := []struct { + name string + args []string + wantContains []string + }{ + { + name: "history", + args: []string{ + "drive", "+version-history", + "--file-token", "boxcnHistoryDryRunUser", + "--limit", "5", + "--cursor", "1777013761763", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnHistoryDryRunUser/history", + `"only_tag": true`, + `"page_size": 5`, + }, + }, + { + name: "get", + args: []string{ + "drive", "+version-get", + "--file-token", "boxcnVersionDryRunUser", + "--version", "7633658129540910621", + "--output", "./artifact-user.bin", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnVersionDryRunUser/download", + `"version": "7633658129540910621"`, + `"output": "./artifact-user.bin"`, + }, + }, + { + name: "revert", + args: []string{ + "drive", "+version-revert", + "--file-token", "boxcnVersionDryRunUser", + "--version", "7633658129540910621", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnVersionDryRunUser/revert", + `"version": "7633658129540910621"`, + }, + }, + { + name: "delete", + args: []string{ + "drive", "+version-delete", + "--file-token", "boxcnVersionDryRunUser", + "--version", "7633658129540910621", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnVersionDryRunUser/version_del", + `"version": "7633658129540910621"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + for _, needle := range tt.wantContains { + assert.Contains(t, output, needle) + } + }) + } +} diff --git a/tests/cli_e2e/drive/drive_version_workflow_test.go b/tests/cli_e2e/drive/drive_version_workflow_test.go new file mode 100644 index 000000000..d1b92df7b --- /dev/null +++ b/tests/cli_e2e/drive/drive_version_workflow_test.go @@ -0,0 +1,234 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDriveVersionWorkflow(t *testing.T) { + if os.Getenv("LARK_DRIVE_VERSION_E2E") == "" { + t.Skip("set LARK_DRIVE_VERSION_E2E=1 to run drive version live workflow") + } + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + fileName := "lark-cli-version-workflow-" + suffix + ".md" + + createResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+create", + "--name", fileName, + "--content", "# v1\n", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + createResult.AssertExitCode(t, 0) + createResult.AssertStdoutStatus(t, true) + + fileToken := gjson.Get(createResult.Stdout, "data.file_token").String() + require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{ + "drive", "+delete", + "--file-token", fileToken, + "--type", "file", + "--yes", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + clie2e.ReportCleanupFailure(parentT, "delete version workflow file "+fileToken, deleteResult, deleteErr) + }) + + overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+overwrite", + "--file-token", fileToken, + "--content", "# v2\n", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + overwriteResult.AssertExitCode(t, 0) + overwriteResult.AssertStdoutStatus(t, true) + + overwriteResult, err = clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+overwrite", + "--file-token", fileToken, + "--content", "# v3\n", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + overwriteResult.AssertExitCode(t, 0) + overwriteResult.AssertStdoutStatus(t, true) + + historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-history", + "--file-token", fileToken, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + historyResult.AssertExitCode(t, 0) + historyResult.AssertStdoutStatus(t, true) + + versions := gjson.Get(historyResult.Stdout, "data.versions").Array() + require.GreaterOrEqual(t, len(versions), 3, "stdout:\n%s", historyResult.Stdout) + + var ( + versionToDownload string + versionV1 string + versionV2 string + ) + for _, version := range versions { + versionID := version.Get("version").String() + if versionID == "" { + continue + } + downloadDir := t.TempDir() + downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-get", + "--file-token", fileToken, + "--version", versionID, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + WorkDir: downloadDir, + }) + require.NoError(t, err) + downloadResult.AssertExitCode(t, 0) + downloadResult.AssertStdoutStatus(t, true) + + downloadedPath := filepath.Join(downloadDir, fileName) + body, err := os.ReadFile(downloadedPath) + require.NoError(t, err) + + switch string(body) { + case "# v1\n": + versionV1 = versionID + case "# v2\n": + versionV2 = versionID + } + if versionToDownload == "" { + versionToDownload = versionID + } + } + require.NotEmpty(t, versionToDownload, "stdout:\n%s", historyResult.Stdout) + require.NotEmpty(t, versionV1, "stdout:\n%s", historyResult.Stdout) + require.NotEmpty(t, versionV2, "stdout:\n%s", historyResult.Stdout) + + downloadDir := t.TempDir() + downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-get", + "--file-token", fileToken, + "--version", versionToDownload, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + WorkDir: downloadDir, + }) + require.NoError(t, err) + downloadResult.AssertExitCode(t, 0) + downloadResult.AssertStdoutStatus(t, true) + + downloadedPath := filepath.Join(downloadDir, fileName) + if _, err := os.Stat(downloadedPath); err != nil { + t.Fatalf("expected downloaded version at %q: %v", downloadedPath, err) + } + + revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-revert", + "--file-token", fileToken, + "--version", versionV1, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + revertResult.AssertExitCode(t, 0) + revertResult.AssertStdoutStatus(t, true) + + fetchResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+fetch", + "--file-token", fileToken, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + fetchResult.AssertExitCode(t, 0) + fetchResult.AssertStdoutStatus(t, true) + if got := gjson.Get(fetchResult.Stdout, "data.content").String(); got != "# v1\n" { + t.Fatalf("markdown content after revert = %q, want %q\nstdout:\n%s", got, "# v1\n", fetchResult.Stdout) + } + + deleteResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-delete", + "--file-token", fileToken, + "--version", versionV2, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + Yes: true, + }) + require.NoError(t, err) + deleteResult.AssertExitCode(t, 0) + deleteResult.AssertStdoutStatus(t, true) + + historyAfterDelete, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-history", + "--file-token", fileToken, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + historyAfterDelete.AssertExitCode(t, 0) + historyAfterDelete.AssertStdoutStatus(t, true) + + foundDeletedVersion := false + for _, version := range gjson.Get(historyAfterDelete.Stdout, "data.versions").Array() { + if version.Get("version").String() != versionV2 { + continue + } + foundDeletedVersion = true + if !version.Get("is_deleted").Bool() { + t.Fatalf("version %s should be marked deleted after +version-delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout) + } + } + if !foundDeletedVersion { + t.Fatalf("deleted version %s not found in history after delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout) + } +} From 4b721c04107351a1b61642cad270e8856173eb38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E5=90=AF=E6=98=8E=EF=BC=88QimingShi=EF=BC=89?= Date: Mon, 18 May 2026 17:34:18 +0800 Subject: [PATCH 13/61] fix(sheets): explicitly document safe JSON unmarshal ignore in DryRun (#935) Two DryRun functions in the sheets shortcuts called json.Unmarshal without checking the return value. This looks like a bug, but Validate already parses and validates the same --style / --data JSON before DryRun runs, so the error is structurally impossible at this point. Use _ = assignment + comment to silence the unchecked-error lint warning and make the safety invariant explicit to future readers. Co-authored-by: KhanCold --- shortcuts/sheets/lark_sheets_cell_style_and_merge.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shortcuts/sheets/lark_sheets_cell_style_and_merge.go b/shortcuts/sheets/lark_sheets_cell_style_and_merge.go index 74679787a..3553fd945 100644 --- a/shortcuts/sheets/lark_sheets_cell_style_and_merge.go +++ b/shortcuts/sheets/lark_sheets_cell_style_and_merge.go @@ -95,7 +95,7 @@ var SheetSetStyle = common.Shortcut{ } r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) var style interface{} - json.Unmarshal([]byte(runtime.Str("style")), &style) + _ = json.Unmarshal([]byte(runtime.Str("style")), &style) // Validate already parses and validates this JSON. return common.NewDryRunAPI(). PUT("/open-apis/sheets/v2/spreadsheets/:token/style"). Body(map[string]interface{}{ @@ -164,7 +164,7 @@ var SheetBatchSetStyle = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } var data interface{} - json.Unmarshal([]byte(runtime.Str("data")), &data) + _ = json.Unmarshal([]byte(runtime.Str("data")), &data) // Validate already parses and validates this JSON via validateBatchStyleData(). normalizeBatchStyleRanges(data) return common.NewDryRunAPI(). PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update"). From df4b657737db0d2356bc986c3161dfea0882f9b5 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Mon, 18 May 2026 19:56:43 +0800 Subject: [PATCH 14/61] feat(drive): add +sync workflow for Drive directories (#873) Bidirectional sync between a local directory and a Drive folder with diff detection (new_local, new_remote, modified, unchanged) and conflict resolution strategies (--on-conflict: remote-wins, local-wins, keep-both, ask). Key behaviors: - Type conflict detection: hard-fail when local file vs remote non-file or local directory vs remote file - Keep-both: rename local with __lark_ suffix, then pull remote; occupied map includes localDirs to prevent suffix collision - Local-wins partial-success: prefer returned file_token on upload failure - Empty directory mirroring: pre-create local dirs on Drive via drivePushWalkLocal before scope preflight - Structured errors throughout (output.Errorf / output.ErrWithHint) Includes unit tests and E2E tests (dry-run + live workflow). --- shortcuts/drive/drive_status_test.go | 58 + shortcuts/drive/drive_sync.go | 650 ++++ shortcuts/drive/drive_sync_test.go | 3097 +++++++++++++++++ shortcuts/drive/shortcuts.go | 1 + shortcuts/drive/shortcuts_test.go | 1 + skills/lark-drive/SKILL.md | 1 + tests/cli_e2e/drive/coverage.md | 5 +- tests/cli_e2e/drive/drive_sync_dryrun_test.go | 258 ++ .../cli_e2e/drive/drive_sync_workflow_test.go | 346 ++ 9 files changed, 4415 insertions(+), 2 deletions(-) create mode 100644 shortcuts/drive/drive_sync.go create mode 100644 shortcuts/drive/drive_sync_test.go create mode 100644 tests/cli_e2e/drive/drive_sync_dryrun_test.go create mode 100644 tests/cli_e2e/drive/drive_sync_workflow_test.go diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index 89c1e42fe..303aeac11 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -17,6 +17,8 @@ import ( "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" ) // driveStatusScopedTokenResolver returns a token with caller-controlled scopes @@ -804,3 +806,59 @@ func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) { t.Fatalf("error must reference --folder-token, got: %v", err) } } + +func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) { + missingRoot := filepath.Join(t.TempDir(), "does-not-exist") + + _, err := walkLocalForStatus(missingRoot, t.TempDir()) + if err == nil { + t.Fatal("expected walkLocalForStatus() to fail for missing root") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected structured ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "io" { + t.Fatalf("expected io error detail, got %#v", exitErr.Detail) + } + if !strings.Contains(err.Error(), "walk") { + t.Fatalf("expected walk-related error, got: %v", err) + } +} + +func TestHashLocalForStatusWrapsOpenError(t *testing.T) { + config := driveTestConfig() + f, _, _, _ := cmdutil.TestFactory(t, config) + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, config) + runtime.Factory = f + + _, err := hashLocalForStatus(runtime, "missing.txt") + if err == nil { + t.Fatal("expected hashLocalForStatus() to fail for missing file") + } + if !strings.Contains(err.Error(), "missing.txt") { + t.Fatalf("expected error to mention the missing file, got: %v", err) + } +} + +func TestHashRemoteForStatusReturnsNetworkErrorWhenDownloadFails(t *testing.T) { + config := driveTestConfig() + f, _, _, _ := cmdutil.TestFactory(t, config) + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{Use: "drive"}, config) + runtime.Factory = f + + _, err := hashRemoteForStatus(context.Background(), runtime, "tok_missing") + if err == nil { + t.Fatal("expected hashRemoteForStatus() to fail when the download request has no stub") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected structured ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "network" { + t.Fatalf("expected network detail, got %#v", exitErr.Detail) + } + if !strings.Contains(err.Error(), "download") { + t.Fatalf("expected download-related error, got: %v", err) + } +} diff --git a/shortcuts/drive/drive_sync.go b/shortcuts/drive/drive_sync.go new file mode 100644 index 000000000..3c512cecf --- /dev/null +++ b/shortcuts/drive/drive_sync.go @@ -0,0 +1,650 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + driveSyncOnConflictLocalWins = "local-wins" + driveSyncOnConflictRemoteWins = "remote-wins" + driveSyncOnConflictKeepBoth = "keep-both" + driveSyncOnConflictAsk = "ask" +) + +type driveSyncItem struct { + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + Action string `json:"action"` + Direction string `json:"direction,omitempty"` // "pull" or "push" + Error string `json:"error,omitempty"` +} + +// DriveSync performs a two-way sync between a local directory and a Drive +// folder. It computes a diff (like +status), then: +// - new_remote → pull (download to local) +// - new_local → push (upload to Drive) +// - modified → resolve by --on-conflict strategy: +// local-wins: push local over remote; +// remote-wins: pull remote over local; +// keep-both: rename the local file with a hash suffix and pull the remote; +// ask: prompt the user per conflict. +var DriveSync = common.Shortcut{ + Service: "drive", + Command: "+sync", + Description: "Two-way sync between a local directory and a Drive folder", + Risk: "write", + Scopes: []string{"drive:drive.metadata:readonly"}, + ConditionalScopes: []string{ + "drive:file:download", + "drive:file:upload", + "space:folder:create", + }, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, + {Name: "folder-token", Desc: "Drive folder token", Required: true}, + {Name: "on-conflict", Desc: "conflict resolution when both sides modified a file", Default: driveSyncOnConflictRemoteWins, Enum: []string{driveSyncOnConflictLocalWins, driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth, driveSyncOnConflictAsk}}, + {Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}}, + {Name: "quick", Type: "bool", Desc: "use best-effort modified_time comparison instead of SHA-256 hash; mismatched timestamps can still trigger real sync writes"}, + }, + Tips: []string{ + "Two-way sync: new remote files are pulled, new local files are pushed, and conflicts (both sides modified) are resolved by --on-conflict.", + "Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.", + "Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).", + "Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.", + "Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + localDir := strings.TrimSpace(runtime.Str("local-dir")) + folderToken := strings.TrimSpace(runtime.Str("folder-token")) + if localDir == "" { + return common.FlagErrorf("--local-dir is required") + } + if folderToken == "" { + return common.FlagErrorf("--folder-token is required") + } + if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { + return output.ErrValidation("%s", err) + } + if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { + return output.ErrValidation("%s", err) + } + info, err := runtime.FileIO().Stat(localDir) + if err != nil { + return common.WrapInputStatError(err) + } + if !info.IsDir() { + return output.ErrValidation("--local-dir is not a directory: %s", localDir) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("Compute diff between --local-dir and --folder-token, then pull new/modified-remote files, push new/modified-local files, and resolve conflicts by --on-conflict strategy."). + GET("/open-apis/drive/v1/files"). + Set("folder_token", runtime.Str("folder-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + localDir := strings.TrimSpace(runtime.Str("local-dir")) + folderToken := strings.TrimSpace(runtime.Str("folder-token")) + onConflict := strings.TrimSpace(runtime.Str("on-conflict")) + if onConflict == "" { + onConflict = driveSyncOnConflictRemoteWins + } + duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote")) + if duplicateRemote == "" { + duplicateRemote = driveDuplicateRemoteFail + } + quick := runtime.Bool("quick") + if !quick { + if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil { + return err + } + } + + safeRoot, err := validate.SafeInputPath(localDir) + if err != nil { + return output.ErrValidation("--local-dir: %s", err) + } + cwdCanonical, err := validate.SafeInputPath(".") + if err != nil { + return output.ErrValidation("could not resolve cwd: %s", err) + } + rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) + if err != nil { + return output.ErrValidation("--local-dir resolves outside cwd: %s", err) + } + + // --- Phase 1: Compute diff (same logic as +status) --- + fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) + localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) + entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") + if err != nil { + return err + } + if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 { + return duplicateRemotePathError(duplicates) + } + + // A local regular file at the same rel_path as a remote + // folder/docx/shortcut is a type conflict: +sync would + // classify it as new_local and attempt to upload, which either + // fails at the API or leaves the remote in a broken state + // (same rel_path with mixed types). Detect early and hard-fail. + // Symmetrically, a local directory at the same rel_path as a + // remote file/docx/shortcut would attempt create_folder and + // produce the same broken mixed-type state. + var typeConflicts []string + for _, entry := range entries { + if entry.Type == driveTypeFile { + continue + } + if _, hasLocal := localFiles[entry.RelPath]; hasLocal { + typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local file vs remote %s", entry.RelPath, entry.Type)) + } + } + // Check local directories vs remote non-folder entries. + // localDirs is not available yet (walked later), so check + // the filesystem directly for the subset of remote paths + // that are non-folder. + for _, entry := range entries { + if entry.Type == driveTypeFolder { + continue + } + dirPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath)) + if info, err := os.Stat(dirPath); err == nil && info.IsDir() { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. + typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local directory vs remote %s", entry.RelPath, entry.Type)) + } + } + if len(typeConflicts) > 0 { + return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; ")) + } + + // Build the exact remote-file views that later execution will use so the + // diff phase classifies files against the same duplicate-resolution choice. + pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%s", err) + } + remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%s", err) + } + + remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles) + + paths := mergeStatusPaths(localFiles, remoteFiles) + + var newLocal, newRemote, modified []driveStatusEntry + var unchanged []driveStatusEntry + for _, relPath := range paths { + localFile, hasLocal := localFiles[relPath] + remoteFile, hasRemote := remoteFiles[relPath] + switch { + case hasLocal && !hasRemote: + newLocal = append(newLocal, driveStatusEntry{RelPath: relPath}) + case !hasLocal && hasRemote: + newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}) + default: + entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken} + if quick { + if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) { + unchanged = append(unchanged, entry) + } else { + modified = append(modified, entry) + } + continue + } + localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd) + if err != nil { + return err + } + remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken) + if err != nil { + return err + } + if localHash == remoteHash { + unchanged = append(unchanged, entry) + } else { + modified = append(modified, entry) + } + } + } + + detection := driveStatusDetectionExact + if quick { + detection = driveStatusDetectionQuick + } + + fmt.Fprintf(runtime.IO().ErrOut, "Diff: %d new_local, %d new_remote, %d modified, %d unchanged (detection=%s)\n", + len(newLocal), len(newRemote), len(modified), len(unchanged), detection) + + conflictResolutions := make(map[string]string, len(modified)) + if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil { + return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist") + } + for _, entry := range modified { + resolved := onConflict + if resolved == driveSyncOnConflictAsk { + resolved, err = driveSyncAskConflict(entry.RelPath, runtime) + if err != nil { + payload := map[string]interface{}{ + "detection": detection, + "diff": map[string]interface{}{ + "new_local": emptyIfNil(newLocal), + "new_remote": emptyIfNil(newRemote), + "modified": emptyIfNil(modified), + "unchanged": emptyIfNil(unchanged), + }, + "summary": map[string]interface{}{ + "pulled": 0, + "pushed": 0, + "skipped": 0, + "failed": 1, + }, + "items": []driveSyncItem{{ + RelPath: entry.RelPath, + FileToken: entry.FileToken, + Action: "failed", + Direction: "conflict", + Error: err.Error(), + }}, + } + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "partial_failure", + Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err), + Detail: payload, + }, + } + } + } + conflictResolutions[entry.RelPath] = resolved + } + + // --- Phase 2: Execute sync operations --- + var pulled, pushed, skipped, failed int + items := make([]driveSyncItem, 0) + + if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) { + if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil { + return err + } + } + plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions) + if len(plannedUploads) > 0 { + if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil { + return err + } + } + + // Build push infrastructure: local walk for push + remote views + folder cache. + folderCache := map[string]string{"": folderToken} + for relDir, entry := range remoteFolders { + folderCache[relDir] = entry.FileToken + } + + // Walk local filesystem early so we can include empty directories + // in the scope preflight (they also need space:folder:create). + pushLocalFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical) + if err != nil { + return err + } + + if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) { + if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil { + return err + } + } + + // Mirror local directory structure first (same as +push), so + // empty local directories are not silently dropped. + for _, relDir := range localDirs { + if _, alreadyRemote := folderCache[relDir]; alreadyRemote { + continue + } + if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { + items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"}) + pushed++ + } + + // 2a. Pull new_remote files. + for _, entry := range newRemote { + targetFile, ok := pullRemoteFiles[entry.RelPath] + if !ok { + // Non-file type (doc, shortcut, etc.) — skip. + continue + } + target := filepath.Join(rootRelToCwd, entry.RelPath) + if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) + pulled++ + } + + // 2b. Push new_local files. + for _, entry := range newLocal { + localFile, ok := pushLocalFiles[entry.RelPath] + if !ok { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"}) + skipped++ + continue + } + parentRel := drivePushParentRel(entry.RelPath) + parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) + if ensureErr != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()}) + failed++ + continue + } + token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) + if upErr != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"}) + pushed++ + } + + // 2c. Resolve modified files by --on-conflict strategy. + for _, entry := range modified { + remoteFile := remoteFiles[entry.RelPath] + localFile, hasLocal := pushLocalFiles[entry.RelPath] + if !hasLocal { + // Should not happen — modified means both sides exist. + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "local file disappeared during sync"}) + skipped++ + continue + } + + resolved := conflictResolutions[entry.RelPath] + if resolved == "" { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "user skipped"}) + skipped++ + continue + } + + switch resolved { + case driveSyncOnConflictRemoteWins: + // Pull remote over local. + targetFile, ok := pullRemoteFiles[entry.RelPath] + if !ok { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: "remote file not found in pull views"}) + failed++ + continue + } + target := filepath.Join(rootRelToCwd, entry.RelPath) + if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) + pulled++ + + case driveSyncOnConflictLocalWins: + // Push local over remote. + existingToken := remoteFile.FileToken + if existingToken == "" { + if chosen, ok := remoteEntriesForPush[entry.RelPath]; ok { + existingToken = chosen.FileToken + } + } + parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache) + if parentErr != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()}) + failed++ + continue + } + token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken) + if upErr != nil { + // Token contract on overwrite failure (same as +push): + // a partial-success response can return a non-empty + // file_token alongside an error. Prefer the freshly + // returned token when one was produced, fall back to + // existingToken otherwise. + failedToken := token + if failedToken == "" { + failedToken = existingToken + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"}) + pushed++ + + case driveSyncOnConflictKeepBoth: + // Rename the local file with a hash suffix, then pull the remote. + // Use the remote file token to generate a stable suffix (same + // pattern as +pull --on-duplicate-remote=rename). + occupied := occupiedRemotePaths(entries) + // Add current local paths to occupied set so the renamed + // local file doesn't collide with an existing file or directory. + for p := range pushLocalFiles { + occupied[p] = struct{}{} + } + for _, relDir := range localDirs { + occupied[relDir] = struct{}{} + } + suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied) + if err != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()}) + failed++ + continue + } + // Rename the local file. + oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath)) + newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel)) + if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)}) + failed++ + continue + } + occupied[suffixedRel] = struct{}{} + // Now pull the remote version to the original path. + targetFile, ok := pullRemoteFiles[entry.RelPath] + if !ok { + rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + errMsg := "remote file not found in pull views after rename" + if rollbackErr != nil { + errMsg += "; rollback failed: " + rollbackErr.Error() + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg}) + failed++ + continue + } + target := filepath.Join(rootRelToCwd, entry.RelPath) + if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { + rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + errMsg := err.Error() + if rollbackErr != nil { + errMsg += "; rollback failed: " + rollbackErr.Error() + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"}) + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) + pulled++ + + default: + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: fmt.Sprintf("unknown conflict strategy: %s", resolved)}) + skipped++ + } + } + + payload := map[string]interface{}{ + "detection": detection, + "diff": map[string]interface{}{ + "new_local": emptyIfNil(newLocal), + "new_remote": emptyIfNil(newRemote), + "modified": emptyIfNil(modified), + "unchanged": emptyIfNil(unchanged), + }, + "summary": map[string]interface{}{ + "pulled": pulled, + "pushed": pushed, + "skipped": skipped, + "failed": failed, + }, + "items": items, + } + + if failed > 0 { + msg := fmt.Sprintf("%d item(s) failed during +sync", failed) + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "partial_failure", + Message: msg, + Detail: payload, + }, + } + } + + runtime.Out(payload, nil) + return nil + }, +} + +func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[string]driveStatusRemoteFile { + remoteFiles := make(map[string]driveStatusRemoteFile, len(pullRemoteFiles)) + for relPath, target := range pullRemoteFiles { + fileToken := target.ItemFileToken + if fileToken == "" { + fileToken = target.DownloadToken + } + remoteFiles[relPath] = driveStatusRemoteFile{FileToken: fileToken, ModifiedTime: target.ModifiedTime} + } + return remoteFiles +} + +// driveSyncAskConflict prompts the user for a conflict resolution strategy +// for a single file. Returns the strategy string, or empty string if the +// user chose to skip. +func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) { + fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath) + if runtime.IO().In == nil { + return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath) + } + reader, ok := runtime.IO().In.(*bufio.Reader) + if !ok { + reader = bufio.NewReader(runtime.IO().In) + runtime.IO().In = reader + } + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err) + } + answer := strings.TrimSpace(strings.ToLower(line)) + if answer == "" { + if errors.Is(err, io.EOF) { + return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath) + } + return driveSyncOnConflictRemoteWins, nil + } + switch answer { + case "l", "local", "local-wins": + return driveSyncOnConflictLocalWins, nil + case "k", "keep", "keep-both": + return driveSyncOnConflictKeepBoth, nil + case "s", "skip": + return "", nil + case "r", "remote", "remote-wins": + return driveSyncOnConflictRemoteWins, nil + default: + return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)) + } +} + +func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool { + if len(newRemote) > 0 { + return true + } + for _, entry := range modified { + switch conflictResolutions[entry.RelPath] { + case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth: + return true + } + } + return false +} + +func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string { + planned := make([]string, 0, len(newLocal)+len(modified)) + for _, entry := range newLocal { + planned = append(planned, entry.RelPath) + } + for _, entry := range modified { + if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins { + planned = append(planned, entry.RelPath) + } + } + return planned +} + +func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool { + for _, relPath := range uploadPaths { + parentRel := drivePushParentRel(relPath) + if parentRel == "" { + continue + } + if _, ok := folderCache[parentRel]; !ok { + return true + } + } + // Empty local directories also need create_folder if not already on Drive. + for _, relDir := range localDirs { + if _, ok := folderCache[relDir]; !ok { + return true + } + } + return false +} + +func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error { + if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. + if info.IsDir() { + return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath) + } + if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. + return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err) + } + } else if !os.IsNotExist(err) { + return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err) + } + if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. + return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err) + } + return nil +} diff --git a/shortcuts/drive/drive_sync_test.go b/shortcuts/drive/drive_sync_test.go new file mode 100644 index 000000000..7364397eb --- /dev/null +++ b/shortcuts/drive/drive_sync_test.go @@ -0,0 +1,3097 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newDriveSyncRuntime(t *testing.T, localDir, folderToken string) (*common.RuntimeContext, *cmdutil.Factory) { + t.Helper() + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, localDir, folderToken) + return runtime, f +} + +func newDriveSyncRuntimeWithFactory(t *testing.T, f *cmdutil.Factory, localDir, folderToken string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "drive +sync"} + cmd.Flags().String("local-dir", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("on-conflict", "", "") + cmd.Flags().String("on-duplicate-remote", "", "") + cmd.Flags().Bool("quick", false, "") + if localDir != "" { + if err := cmd.Flags().Set("local-dir", localDir); err != nil { + t.Fatalf("set --local-dir: %v", err) + } + } + if folderToken != "" { + if err := cmd.Flags().Set("folder-token", folderToken); err != nil { + t.Fatalf("set --folder-token: %v", err) + } + } + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, driveTestConfig()) + runtime.Factory = f + return runtime +} + +type failSaveProvider struct { + inner fileio.Provider + failSuffix string + err error +} + +func (p *failSaveProvider) Name() string { return "fail-save" } + +func (p *failSaveProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + return &failSaveFileIO{inner: p.inner.ResolveFileIO(ctx), failSuffix: p.failSuffix, err: p.err} +} + +type failSaveFileIO struct { + inner fileio.FileIO + failSuffix string + err error +} + +func (f *failSaveFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) } +func (f *failSaveFileIO) Stat(name string) (fileio.FileInfo, error) { return f.inner.Stat(name) } +func (f *failSaveFileIO) ResolvePath(path string) (string, error) { return f.inner.ResolvePath(path) } + +func (f *failSaveFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + if strings.HasSuffix(path, f.failSuffix) { + return nil, f.err + } + return f.inner.Save(path, opts, body) +} + +type deleteOnCloseProvider struct { + inner fileio.Provider + targetPath string + deletePath string +} + +func (p *deleteOnCloseProvider) Name() string { return "delete-on-close" } + +func (p *deleteOnCloseProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + return &deleteOnCloseFileIO{inner: p.inner.ResolveFileIO(ctx), targetPath: p.targetPath, deletePath: p.deletePath} +} + +type deleteOnCloseFileIO struct { + inner fileio.FileIO + targetPath string + deletePath string +} + +func (f *deleteOnCloseFileIO) Open(name string) (fileio.File, error) { + file, err := f.inner.Open(name) + if err != nil { + return nil, err + } + if name != f.targetPath { + return file, nil + } + return &deleteOnCloseFile{File: file, deletePath: f.deletePath}, nil +} + +func (f *deleteOnCloseFileIO) Stat(name string) (fileio.FileInfo, error) { return f.inner.Stat(name) } +func (f *deleteOnCloseFileIO) ResolvePath(path string) (string, error) { + return f.inner.ResolvePath(path) +} +func (f *deleteOnCloseFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + return f.inner.Save(path, opts, body) +} + +type deleteOnCloseFile struct { + fileio.File + deletePath string +} + +func (f *deleteOnCloseFile) Close() error { + err := f.File.Close() + _ = os.Remove(f.deletePath) + return err +} + +type failAfterSaveProvider struct { + inner fileio.Provider + failSuffix string + err error + afterSave func(path string) +} + +func (p *failAfterSaveProvider) Name() string { return "fail-after-save" } + +func (p *failAfterSaveProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + return &failAfterSaveFileIO{inner: p.inner.ResolveFileIO(ctx), failSuffix: p.failSuffix, err: p.err, afterSave: p.afterSave} +} + +type failAfterSaveFileIO struct { + inner fileio.FileIO + failSuffix string + err error + afterSave func(path string) +} + +func (f *failAfterSaveFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) } +func (f *failAfterSaveFileIO) Stat(name string) (fileio.FileInfo, error) { return f.inner.Stat(name) } +func (f *failAfterSaveFileIO) ResolvePath(path string) (string, error) { + return f.inner.ResolvePath(path) +} + +func (f *failAfterSaveFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + res, err := f.inner.Save(path, opts, body) + if strings.HasSuffix(path, f.failSuffix) { + if f.afterSave != nil { + f.afterSave(path) + } + return res, f.err + } + return res, err +} + +type driveSyncReadThenError struct { + stage int +} + +func (r *driveSyncReadThenError) Read(p []byte) (int, error) { + if r.stage == 0 { + r.stage++ + copy(p, []byte("local ")) + return 6, nil + } + return 0, fmt.Errorf("read failure") +} + +// TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal verifies the basic +// two-way sync flow: new_remote files are pulled, new_local files are pushed, +// and modified files use --on-conflict=remote-wins (the default) to pull the +// remote version. +func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-remote-wins", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + // Local layout: + // local/b.txt — only local → push + // local/a.txt — both sides, different content → conflict (remote-wins → pull) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + // Remote listing: a.txt (modified), d.txt (new_remote) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison (exact mode) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Download d.txt (new_remote → pull) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_d/download", + Status: 200, + Body: []byte("remote-d"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Download a.txt again (conflict: remote-wins → pull remote over local) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Upload b.txt (new_local → push) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_b_uploaded", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "downloaded"`) { + t.Errorf("output missing downloaded action\noutput: %s", out) + } + if !strings.Contains(out, `"action": "uploaded"`) { + t.Errorf("output missing uploaded action\noutput: %s", out) + } + if !strings.Contains(out, `"direction": "pull"`) { + t.Errorf("output missing pull direction\noutput: %s", out) + } + if !strings.Contains(out, `"direction": "push"`) { + t.Errorf("output missing push direction\noutput: %s", out) + } + + // Verify local file was overwritten with remote content + data, err := os.ReadFile("local/a.txt") + if err != nil { + t.Fatalf("ReadFile a.txt: %v", err) + } + if string(data) != "remote-a" { + t.Errorf("a.txt content = %q, want %q", string(data), "remote-a") + } + + // Verify d.txt was downloaded + data, err = os.ReadFile("local/d.txt") + if err != nil { + t.Fatalf("ReadFile d.txt: %v", err) + } + if string(data) != "remote-d" { + t.Errorf("d.txt content = %q, want %q", string(data), "remote-d") + } +} + +// TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins +// pushes the local version over the remote file. +func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison (exact mode) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Upload a.txt with overwrite (local-wins → push over remote) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_a", + "version": "v2", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "overwritten"`) { + t.Errorf("output missing overwritten action\noutput: %s", out) + } + if !strings.Contains(out, `"direction": "push"`) { + t.Errorf("output missing push direction\noutput: %s", out) + } +} + +// TestDriveSyncKeepBothRenamesLocalAndPullsRemote verifies that +// --on-conflict=keep-both renames the local file with a hash suffix +// and then downloads the remote version to the original path. +func TestDriveSyncKeepBothRenamesLocalAndPullsRemote(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Download a.txt again (keep-both: pull remote to original path after rename) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "renamed_local"`) { + t.Errorf("output missing renamed_local action\noutput: %s", out) + } + if !strings.Contains(out, `"action": "downloaded"`) { + t.Errorf("output missing downloaded action\noutput: %s", out) + } + + // Original path should now have remote content + data, err := os.ReadFile("local/a.txt") + if err != nil { + t.Fatalf("ReadFile a.txt: %v", err) + } + if string(data) != "remote-a" { + t.Errorf("a.txt content = %q, want %q", string(data), "remote-a") + } + + // There should be a renamed file with __lark_ suffix + entries, err := os.ReadDir("local") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + found := false + for _, e := range entries { + if strings.Contains(e.Name(), "__lark_") && strings.HasSuffix(e.Name(), ".txt") { + found = true + renamedData, err := os.ReadFile("local/" + e.Name()) + if err != nil { + t.Fatalf("ReadFile renamed: %v", err) + } + if string(renamedData) != "local-a" { + t.Errorf("renamed file content = %q, want %q", string(renamedData), "local-a") + } + } + } + if !found { + t.Errorf("expected a file with __lark_ suffix in local/, got entries: %v", entries) + } +} + +// TestDriveSyncKeepBothRollsBackRenameOnPullFailure verifies that keep-both +// restores the original local path if the remote download fails after the +// local file has been renamed. +func TestDriveSyncKeepBothRollsBackRenameOnPullFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both-rollback", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for the exact diff phase. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected +sync keep-both to fail when the post-rename pull has no stub\nstdout: %s", stdout.String()) + } + + data, readErr := os.ReadFile("local/a.txt") + if readErr != nil { + t.Fatalf("ReadFile a.txt after rollback: %v", readErr) + } + if string(data) != "local-a" { + t.Fatalf("a.txt content after rollback = %q, want %q", string(data), "local-a") + } + + entries, readDirErr := os.ReadDir("local") + if readDirErr != nil { + t.Fatalf("ReadDir local: %v", readDirErr) + } + if len(entries) != 1 || entries[0].Name() != "a.txt" { + t.Fatalf("expected rollback to restore only local/a.txt, got entries: %v", entries) + } +} + +// TestDriveSyncAskConflictFailsBeforeWritesWithoutStdin verifies that +// --on-conflict=ask fails before any sync writes start when stdin is not +// available and the diff contains modified entries. +func TestDriveSyncAskConflictFailsBeforeWritesWithoutStdin(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-ask-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for the exact diff phase. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "ask", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected +sync --on-conflict=ask to fail on EOF\nstdout: %s", stdout.String()) + } + if !strings.Contains(err.Error(), "interactive stdin") { + t.Fatalf("expected interactive stdin validation error, got: %v", err) + } + + data, readErr := os.ReadFile("local/a.txt") + if readErr != nil { + t.Fatalf("ReadFile a.txt after ask failure: %v", readErr) + } + if string(data) != "local-a" { + t.Fatalf("a.txt content after ask failure = %q, want %q", string(data), "local-a") + } + if _, statErr := os.Stat("local/d.txt"); !os.IsNotExist(statErr) { + t.Fatalf("new_remote download should not start before ask preflight; stat err=%v", statErr) + } +} + +func TestDriveSyncFailsOnDuplicateRemoteFiles(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond) + if stdout.Len() != 0 { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } +} + +// TestDriveSyncUsesResolvedDuplicateTargetForDiff verifies that +sync computes +// the diff against the same duplicate-remote selection used during execution. +func TestDriveSyncUsesResolvedDuplicateTargetForDiff(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-duplicate-resolution", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("same-as-oldest"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_old", "name": "a.txt", "type": "file", "created_time": "100", "modified_time": "100"}, + map[string]interface{}{"token": "tok_new", "name": "a.txt", "type": "file", "created_time": "200", "modified_time": "200"}, + }, + "has_more": false, + }, + }, + }) + + // The chosen --on-duplicate-remote=oldest target is tok_old. The test omits + // any tok_new download stub so a stale last-seen overwrite bug would fail. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_old/download", + Status: 200, + Body: []byte("same-as-oldest"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "oldest", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"pushed": 0`) || !strings.Contains(out, `"pulled": 0`) { + t.Fatalf("expected unchanged duplicate target to produce no sync actions\noutput: %s", out) + } + if !strings.Contains(out, `"file_token": "tok_old"`) { + t.Fatalf("expected diff to reference the oldest duplicate target token\noutput: %s", out) + } +} + +// TestDriveSyncLocalWinsNestedFileUsesParentFolderToken verifies that local-wins +// overwrites on nested files keep parent_node aligned with the file's parent. +func TestDriveSyncLocalWinsNestedFileUsesParentFolderToken(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-nested", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local/sub", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/sub/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "fld_sub", "name": "sub", "type": "folder"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=fld_sub", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Diff phase exact hash download. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_a", + "version": "v2", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != "tok_a" { + t.Fatalf("upload_all file_token = %q, want tok_a", got) + } + if got := body.Fields["parent_node"]; got != "fld_sub" { + t.Fatalf("upload_all parent_node = %q, want fld_sub", got) + } +} + +// TestDriveSyncNewLocalDisappearanceIsReported verifies that files discovered +// during diff but removed before the push phase are surfaced as skipped items +// instead of being silently dropped. +func TestDriveSyncNewLocalDisappearanceIsReported(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-local-disappeared", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/ephemeral.txt", []byte("temp"), 0o644); err != nil { + t.Fatalf("WriteFile ephemeral.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + OnMatch: func(_ *http.Request) { + if err := os.Remove("local/ephemeral.txt"); err != nil && !os.IsNotExist(err) { + t.Fatalf("Remove ephemeral.txt in OnMatch: %v", err) + } + }, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{}, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"skipped": 1`) { + t.Fatalf("expected skipped=1 when new_local disappears during execution\noutput: %s", out) + } + if !strings.Contains(out, `"rel_path": "ephemeral.txt"`) || !strings.Contains(out, `"local file disappeared during sync"`) { + t.Fatalf("expected vanished new_local file to be reported in items\noutput: %s", out) + } +} + +// TestDriveSyncQuickModeUsesModifiedTime verifies that --quick mode +// classifies files by modified_time instead of SHA-256 hash. +func TestDriveSyncQuickModeUsesModifiedTime(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-quick", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + // Set a.txt mtime to match remote → unchanged in quick mode + matchTime := time.Unix(1715594880, 0) + if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil { + t.Fatalf("Chtimes a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"}, + }, + "has_more": false, + }, + }, + }) + + // Download d.txt (new_remote → pull) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_d/download", + Status: 200, + Body: []byte("remote-d"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Upload b.txt (new_local → push) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_b_uploaded", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"detection": "quick"`) { + t.Errorf("output missing detection=quick\noutput: %s", out) + } + // a.txt should be unchanged (mtime matches), not downloaded or uploaded + // It should appear in diff.unchanged but NOT in items[] with a pull/push action + itemsSection := out[strings.Index(out, `"items"`):] + if strings.Contains(itemsSection, `"rel_path": "a.txt"`) { + t.Errorf("a.txt should not appear in items[] (mtime matches remote, should be unchanged)\noutput: %s", out) + } +} + +// TestDriveSyncQuickModeMTimeMismatchStillTriggersWrites verifies the best-effort +// nature of --quick: a timestamp mismatch alone is enough to drive a real sync +// action even when the file bytes are already identical. +func TestDriveSyncQuickModeMTimeMismatchStillTriggersWrites(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-quick-mismatch", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("same-content"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + localTime := time.Unix(1715594880, 0) + if err := os.Chtimes("local/a.txt", localTime, localTime); err != nil { + t.Fatalf("Chtimes a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594999"}, + }, + "has_more": false, + }, + }, + }) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("same-content"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"detection": "quick"`) { + t.Fatalf("expected detection=quick\noutput: %s", out) + } + if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"action": "downloaded"`) { + t.Fatalf("expected quick mtime mismatch to trigger a real pull action\noutput: %s", out) + } +} + +// TestDriveSyncNoChangesReportsEmptyItems verifies that when local and remote +// are identical, +sync reports zero pulled/pushed items. +func TestDriveSyncNoChangesReportsEmptyItems(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-no-changes", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("same"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison → same content → unchanged + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("same"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"pulled": 0`) { + t.Errorf("expected pulled=0\noutput: %s", out) + } + if !strings.Contains(out, `"pushed": 0`) { + t.Errorf("expected pushed=0\noutput: %s", out) + } + if !strings.Contains(out, `"failed": 0`) { + t.Errorf("expected failed=0\noutput: %s", out) + } +} + +func TestDriveSyncValidateRejectsInvalidInputs(t *testing.T) { + t.Run("missing local-dir", func(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--local-dir is required") { + t.Fatalf("Validate() error = %v, want missing --local-dir", err) + } + }) + + t.Run("missing folder-token", func(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "local", "") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--folder-token is required") { + t.Fatalf("Validate() error = %v, want missing --folder-token", err) + } + }) + + t.Run("malformed folder-token", func(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "local", "tok\nwithnewline") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--folder-token") { + t.Fatalf("Validate() error = %v, want malformed folder-token error", err) + } + }) + + t.Run("absolute local-dir", func(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "/etc", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--local-dir") { + t.Fatalf("Validate() error = %v, want invalid local-dir error", err) + } + }) + + t.Run("missing local-dir path", func(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + runtime, _ := newDriveSyncRuntime(t, "missing", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("Validate() error = %v, want missing-path error", err) + } + }) + + t.Run("local-dir is file", func(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "not-a-dir.txt", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("Validate() error = %v, want not-a-directory error", err) + } + }) +} + +func TestDriveSyncDryRunUsesFolderToken(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "local", "folder_root") + dry := DriveSync.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + if !strings.Contains(string(data), `"folder_token":"folder_root"`) { + t.Fatalf("dry run missing folder_token, got: %s", string(data)) + } +} + +func TestDriveSyncExecuteRejectsUnsafeLocalDir(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "/etc", "folder_root") + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--local-dir") { + t.Fatalf("Execute() error = %v, want unsafe local-dir validation error", err) + } +} + +func TestDriveSyncAskConflictParsesChoices(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr string + }{ + {name: "blank line defaults remote wins", input: "\n", want: driveSyncOnConflictRemoteWins}, + {name: "local short form", input: "L\n", want: driveSyncOnConflictLocalWins}, + {name: "keep both long form", input: "keep-both\n", want: driveSyncOnConflictKeepBoth}, + {name: "skip returns empty resolution", input: "skip\n", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader(tt.input) + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + got, err := driveSyncAskConflict("a.txt", runtime) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("driveSyncAskConflict() error = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("driveSyncAskConflict() unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("driveSyncAskConflict() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDriveSyncAskConflictRejectsMissingStdin(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + _, err := driveSyncAskConflict("a.txt", runtime) + if err == nil || !strings.Contains(err.Error(), "stdin is not available") { + t.Fatalf("driveSyncAskConflict() error = %v, want stdin availability error", err) + } +} + +func TestDriveSyncAskConflictHandlesEOFAndReadErrors(t *testing.T) { + t.Run("blank EOF without answer fails", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader("") + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + _, err := driveSyncAskConflict("a.txt", runtime) + if err == nil || !strings.Contains(err.Error(), "stdin reached EOF") { + t.Fatalf("driveSyncAskConflict() error = %v, want EOF failure", err) + } + }) + + t.Run("partial token before EOF is still accepted", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader("local") + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + got, err := driveSyncAskConflict("a.txt", runtime) + if err != nil { + t.Fatalf("driveSyncAskConflict() unexpected error: %v", err) + } + if got != driveSyncOnConflictLocalWins { + t.Fatalf("driveSyncAskConflict() = %q, want %q", got, driveSyncOnConflictLocalWins) + } + }) + + t.Run("unknown answer returns validation error", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader("what\n") + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + _, err := driveSyncAskConflict("a.txt", runtime) + if err == nil || !strings.Contains(err.Error(), "invalid conflict choice") { + t.Fatalf("driveSyncAskConflict() error = %v, want invalid-choice failure", err) + } + }) + + t.Run("non EOF read failure returns wrapped error", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = bufio.NewReader(&driveSyncReadThenError{}) + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + _, err := driveSyncAskConflict("a.txt", runtime) + if err == nil || !strings.Contains(err.Error(), "cannot read conflict choice") { + t.Fatalf("driveSyncAskConflict() error = %v, want wrapped read failure", err) + } + }) +} + +func TestDriveSyncRollbackRenamedLocalRestoresRenamedFile(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/a__lark.txt" + + if err := os.WriteFile(oldAbsPath, []byte("partial remote"), 0o644); err != nil { + t.Fatalf("WriteFile oldAbsPath: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + + if err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath); err != nil { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v", err) + } + + data, err := os.ReadFile(oldAbsPath) + if err != nil { + t.Fatalf("ReadFile restored oldAbsPath: %v", err) + } + if got := string(data); got != "original local" { + t.Fatalf("restored content = %q, want %q", got, "original local") + } + if _, err := os.Stat(newAbsPath); !os.IsNotExist(err) { + t.Fatalf("expected renamed path to be removed after rollback, stat err = %v", err) + } +} + +func TestDriveSyncRollbackRenamedLocalWithoutPartialRestore(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/a__lark.txt" + + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + + if err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath); err != nil { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v", err) + } + + data, err := os.ReadFile(oldAbsPath) + if err != nil { + t.Fatalf("ReadFile restored oldAbsPath: %v", err) + } + if got := string(data); got != "original local" { + t.Fatalf("restored content = %q, want %q", got, "original local") + } +} + +func TestDriveSyncRollbackRenamedLocalRejectsDirectoryAtOriginalPath(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/a__lark.txt" + + if err := os.Mkdir(oldAbsPath, 0o755); err != nil { + t.Fatalf("Mkdir oldAbsPath: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "became a directory") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want directory error", err) + } +} + +func TestDriveSyncRollbackRenamedLocalSurfacesRenameFailure(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/missing.txt" + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "restore renamed local file") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want rename failure", err) + } +} + +func TestDriveSyncRollbackRenamedLocalSurfacesRemoveFailure(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := filepath.Join(tmpDir, "a.txt") + newAbsPath := filepath.Join(tmpDir, "a__lark.txt") + + if err := os.WriteFile(oldAbsPath, []byte("partial remote"), 0o644); err != nil { + t.Fatalf("WriteFile oldAbsPath: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + if err := os.Chmod(tmpDir, 0o555); err != nil { + t.Fatalf("Chmod read-only dir: %v", err) + } + defer func() { + _ = os.Chmod(tmpDir, 0o755) + }() + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "remove partial restored path") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want remove failure", err) + } +} + +func TestDriveSyncRollbackRenamedLocalSurfacesStatFailure(t *testing.T) { + tmpDir := t.TempDir() + blockedDir := filepath.Join(tmpDir, "blocked") + oldAbsPath := filepath.Join(blockedDir, "a.txt") + newAbsPath := filepath.Join(blockedDir, "a__lark.txt") + + if err := os.MkdirAll(blockedDir, 0o755); err != nil { + t.Fatalf("MkdirAll blockedDir: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + if err := os.Chmod(blockedDir, 0o000); err != nil { + t.Fatalf("Chmod blockedDir: %v", err) + } + defer func() { + _ = os.Chmod(blockedDir, 0o755) + }() + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "stat original path") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want stat failure", err) + } +} + +func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-ask-exec-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.IOStreams.In = strings.NewReader("") + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "ask", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected EOF failure during ask execution\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || !strings.Contains(items[0].Error, "stdin reached EOF") { + t.Fatalf("expected failed ask item, got detail: %#v", exitErr.Detail.Detail) + } + data, readErr := os.ReadFile("local/a.txt") + if readErr != nil { + t.Fatalf("ReadFile a.txt: %v", readErr) + } + if string(data) != "local-a" { + t.Fatalf("a.txt content = %q, want local-a", string(data)) + } +} + +func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-ask-plan-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.IOStreams.In = strings.NewReader("") + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "ask", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected EOF failure during ask planning\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + if exitErr.Detail.Type != "partial_failure" || !strings.Contains(exitErr.Error(), "stdin reached EOF") { + t.Fatalf("expected planning failure detail mentioning EOF, got: %#v", exitErr.Detail) + } + if data, readErr := os.ReadFile("local/a.txt"); readErr != nil || string(data) != "local-a" { + t.Fatalf("a.txt should remain untouched, readErr=%v content=%q", readErr, string(data)) + } + if data, readErr := os.ReadFile("local/b.txt"); readErr != nil || string(data) != "local-b" { + t.Fatalf("b.txt should remain untouched, readErr=%v content=%q", readErr, string(data)) + } + if _, statErr := os.Stat("local/d.txt"); !os.IsNotExist(statErr) { + t.Fatalf("new_remote file must not be downloaded before ask decisions, stat err=%v", statErr) + } +} + +func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly"}, nil) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected quick dry-run to succeed without write scopes, got: %v\nstdout: %s", err, stdout.String()) + } + if strings.Contains(strings.ToLower(stdout.String()), "missing_scope") { + t.Fatalf("dry-run should not surface missing_scope, got: %s", stdout.String()) + } +} + +func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected exact remote-wins to succeed with download-only scope, got: %v\nstdout: %s", err, stdout.String()) + } + if strings.Contains(strings.ToLower(stdout.String()), "missing_scope") { + t.Fatalf("should not surface missing_scope, got: %s", stdout.String()) + } +} + +func TestDriveSyncAskConflictSkipReportsSkippedItem(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-ask-skip", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.IOStreams.In = strings.NewReader("skip\n") + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "ask", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + out := stdout.String() + if !strings.Contains(out, `"action": "skipped"`) || !strings.Contains(out, "user skipped") { + t.Fatalf("expected skipped conflict item, got: %s", out) + } + if !strings.Contains(out, `"skipped": 1`) { + t.Fatalf("expected skipped summary count, got: %s", out) + } +} + +func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-remote-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "d.txt"), err: fmt.Errorf("save failed")} + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_d/download", + Status: 200, + Body: []byte("remote-d"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected download failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { + t.Fatalf("expected failed pull item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-local-ensure-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "a.txt"), []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 9999, + "msg": "create parent failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected ensure failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "create parent failed") { + t.Fatalf("expected failed push item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-local-upload-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 9999, + "msg": "upload failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected upload failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "upload failed") { + t.Fatalf("expected failed upload item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-upload-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 9999, + "msg": "overwrite failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "overwrite failed") { + t.Fatalf("expected failed overwrite item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both-rename-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + // Exhaust all possible suffixed paths so that + // relPathWithUniqueFileTokenSuffix cannot find a free name. + // The function tries 12-char, 24-char, 64-char hash prefixes, + // then _2 through _N sequential suffixes. + // We create local blocker files at each candidate path; they become + // new_local items (uploaded via the reusable stub) and occupy the + // suffixed names in the keep-both occupied map. + tokenHash := stableTokenHash("tok_a") + candidates := []string{ + relPathWithSuffix("a.txt", "__lark_"+tokenHash[:12]), + relPathWithSuffix("a.txt", "__lark_"+tokenHash[:24]), + relPathWithSuffix("a.txt", "__lark_"+tokenHash), + } + for i := 2; i <= driveUniqueSuffixMaxSeq; i++ { + candidates = append(candidates, relPathWithSuffix("a.txt", "__lark_"+tokenHash+"_"+strconv.Itoa(i))) + } + for _, c := range candidates { + full := filepath.Join("local", filepath.FromSlash(c)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("MkdirAll parent of %s: %v", c, err) + } + if err := os.WriteFile(full, []byte("blocker"), 0o644); err != nil { + t.Fatalf("WriteFile %s: %v", c, err) + } + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + // Reusable upload stub: all blocker files (new_local) upload successfully. + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Reusable: true, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_blocker", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--quick", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected keep-both suffix exhaustion error\nstdout: %s", stdout.String()) + } + // The error may be a plain ExitError (no Detail.Detail) or a + // partial_failure with items. Either way it must mention the + // suffix exhaustion. + errMsg := err.Error() + // The suffix exhaustion message may be in the top-level error or + // inside a partial_failure detail item. Check both. + foundSuffixError := strings.Contains(errMsg, "could not generate a unique rel_path") + if !foundSuffixError { + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + for _, item := range items { + if strings.Contains(item.Error, "could not generate a unique rel_path") { + foundSuffixError = true + break + } + } + if !foundSuffixError { + t.Fatalf("expected suffix exhaustion error, got: %s; detail: %#v", errMsg, exitErr.Detail.Detail) + } + } else { + t.Fatalf("expected suffix exhaustion error, got: %s", errMsg) + } + } +} + +func TestDriveSyncExecuteReturnsRemoteListError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "local", "folder_root") + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "API call failed") { + t.Fatalf("Execute() error = %v, want remote list error", err) + } +} + +func TestDriveSyncExecuteReturnsLocalWalkError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "local", "folder_root") + if err := os.RemoveAll("local"); err != nil { + t.Fatalf("RemoveAll local: %v", err) + } + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "walk") { + t.Fatalf("Execute() error = %v, want local walk error", err) + } +} + +func TestDriveSyncExecuteWrapsInvalidDuplicateStrategyForPullViews(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + if err := runtime.Cmd.Flags().Set("on-duplicate-remote", "invalid-strategy"); err != nil { + t.Fatalf("set --on-duplicate-remote: %v", err) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_b", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "unsupported duplicate remote strategy") { + t.Fatalf("Execute() error = %v, want pull views strategy error", err) + } +} + +func TestDriveSyncExecuteWrapsUnsupportedPushDuplicateStrategy(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + if err := runtime.Cmd.Flags().Set("on-duplicate-remote", driveDuplicateRemoteRename); err != nil { + t.Fatalf("set --on-duplicate-remote: %v", err) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_b", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "unsupported duplicate remote strategy") { + t.Fatalf("Execute() error = %v, want push views strategy error", err) + } +} + +func TestDriveSyncExecuteSurfacesHashLocalError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o000); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + defer func() { _ = os.Chmod("local/a.txt", 0o644) }() + + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("Execute() error = %v, want hashLocal error", err) + } +} + +func TestDriveSyncExecuteSurfacesHashRemoteError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "download") { + t.Fatalf("Execute() error = %v, want hashRemote error", err) + } +} + +func TestDriveSyncExecuteReturnsPushWalkErrorAfterDiff(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + OnMatch: func(req *http.Request) { + _ = os.RemoveAll("local") + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "walk") { + t.Fatalf("Execute() error = %v, want push walk error", err) + } +} + +func TestDriveSyncExecuteUnknownConflictStrategySkipsModifiedFile(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + if err := runtime.Cmd.Flags().Set("on-conflict", "mystery-mode"); err != nil { + t.Fatalf("set --on-conflict: %v", err) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err != nil { + t.Fatalf("Execute() unexpected error: %v", err) + } +} + +func TestDriveSyncModifiedFileDisappearingBeforeExecuteIsSkipped(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-modified-disappears", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.FileIOProvider = &deleteOnCloseProvider{ + inner: f.FileIOProvider, + targetPath: filepath.Join("local", "a.txt"), + deletePath: filepath.Join("local", "a.txt"), + } + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + out := stdout.String() + if !strings.Contains(out, `"direction": "conflict"`) || !strings.Contains(out, "local file disappeared during sync") { + t.Fatalf("expected modified file disappearance to be reported, got: %s", out) + } + if !strings.Contains(out, `"skipped": 1`) { + t.Fatalf("expected skipped summary count, got: %s", out) + } +} + +func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-remote-wins-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "a.txt"), err: fmt.Errorf("save failed")} + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Reusable: true, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected modified pull failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { + t.Fatalf("expected failed modified pull item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both-rollback-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + f.FileIOProvider = &failAfterSaveProvider{ + inner: f.FileIOProvider, + failSuffix: filepath.Join("local", "a.txt"), + err: fmt.Errorf("save failed"), + afterSave: func(path string) { + _ = os.Chmod(filepath.Dir(path), 0o555) + }, + } + defer func() { + _ = os.Chmod(filepath.Join(tmpDir, "local"), 0o755) + }() + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Reusable: true, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected keep-both rollback failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || !strings.Contains(items[0].Error, "rollback failed") { + t.Fatalf("expected rollback failure in item error, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncStatusRemoteFilesUsesStableTokens(t *testing.T) { + remoteFiles := driveSyncStatusRemoteFiles(map[string]drivePullTarget{ + "item-token.txt": { + DownloadToken: "download_token_should_not_win", + ItemFileToken: "item_file_token", + ModifiedTime: "111", + }, + "download-token.txt": { + DownloadToken: "download_only_token", + ModifiedTime: "222", + }, + }) + + if got := remoteFiles["item-token.txt"].FileToken; got != "item_file_token" { + t.Fatalf("item-token.txt file_token = %q, want item_file_token", got) + } + if got := remoteFiles["download-token.txt"].FileToken; got != "download_only_token" { + t.Fatalf("download-token.txt file_token = %q, want download_only_token", got) + } + if got := remoteFiles["download-token.txt"].ModifiedTime; got != "222" { + t.Fatalf("download-token.txt modified_time = %q, want 222", got) + } +} + +func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-parent-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "a.txt"), []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_nested", "name": "sub/a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_nested/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 9999, + "msg": "create parent failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || !strings.Contains(items[0].Error, "create parent failed") { + t.Fatalf("expected failed item with create_folder error, got detail: %#v", exitErr.Detail.Detail) + } +} + +// TestDriveSyncSkipsNonFileRemoteEntries verifies that new_remote entries +// whose rel_path is not in pullRemoteFiles (non-file types like docx, +// shortcuts) are silently skipped rather than causing a panic or error. +func TestDriveSyncSkipsNonFileRemoteEntries(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-skip-nonfile", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Remote has a docx and a shortcut — both should be skipped in pull. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_doc", "name": "notes.docx", "type": "docx"}, + map[string]interface{}{"token": "tok_sc", "name": "link.lnk", "type": "shortcut"}, + }, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"pulled": 0`) { + t.Fatalf("expected pulled=0 (non-file entries skipped), got: %s", out) + } + if !strings.Contains(out, `"pushed": 0`) { + t.Fatalf("expected pushed=0, got: %s", out) + } +} + +// TestDriveSyncAskConflictRemoteShortForms verifies the "r", "remote", +// and "remote-wins" input variants all resolve to remote-wins. +func TestDriveSyncAskConflictRemoteShortForms(t *testing.T) { + tests := []struct { + name string + input string + }{ + {name: "r", input: "r\n"}, + {name: "remote", input: "remote\n"}, + {name: "remote-wins", input: "remote-wins\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader(tt.input) + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + got, err := driveSyncAskConflict("a.txt", runtime) + if err != nil { + t.Fatalf("driveSyncAskConflict() unexpected error: %v", err) + } + if got != driveSyncOnConflictRemoteWins { + t.Fatalf("driveSyncAskConflict() = %q, want %q", got, driveSyncOnConflictRemoteWins) + } + }) + } +} + +// TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly verifies +// that driveSyncNeedsDownloadScope returns false when there are no +// new_remote entries and all modified entries resolve to local-wins. +func TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly(t *testing.T) { + modified := []driveStatusEntry{{RelPath: "a.txt"}, {RelPath: "b.txt"}} + resolutions := map[string]string{"a.txt": driveSyncOnConflictLocalWins, "b.txt": driveSyncOnConflictLocalWins} + + if driveSyncNeedsDownloadScope(nil, modified, resolutions) { + t.Fatal("expected false when no new_remote and all conflicts are local-wins") + } +} + +// TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth verifies that +// driveSyncNeedsDownloadScope returns true when a modified entry resolves +// to keep-both (which requires pulling the remote version). +func TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth(t *testing.T) { + modified := []driveStatusEntry{{RelPath: "a.txt"}} + resolutions := map[string]string{"a.txt": driveSyncOnConflictKeepBoth} + + if !driveSyncNeedsDownloadScope(nil, modified, resolutions) { + t.Fatal("expected true when a conflict resolves to keep-both") + } +} + +// TestDriveSyncRemoteWinsReportsMissingPullView verifies that when a +// modified file's rel_path is not in pullRemoteFiles during the +// remote-wins branch, a failed item is reported instead of a panic. +// This can happen when duplicate remote entries are resolved differently +// between pull and status views. +func TestDriveSyncRemoteWinsReportsMissingPullView(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + if err := runtime.Cmd.Flags().Set("on-duplicate-remote", "invalid-strategy"); err != nil { + t.Fatalf("set --on-duplicate-remote: %v", err) + } + // Two remote files with the same name — the invalid duplicate strategy + // will cause drivePullRemoteViews to return an error, which is wrapped + // as an internal error before we even reach the remote-wins branch. + // To test the "remote file not found in pull views" branch directly, + // we use a unit-level approach instead. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_b", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil { + t.Fatalf("expected error for invalid duplicate strategy\nstdout: %s", err) + } + if !strings.Contains(err.Error(), "unsupported duplicate remote strategy") { + t.Fatalf("expected strategy error, got: %v", err) + } +} + +// TestDriveSyncKeepBothReportsSuffixError verifies that keep-both reports +// a failed item when relPathWithUniqueFileTokenSuffix cannot find a +// unique name because all candidates are already occupied. +func TestDriveSyncKeepBothReportsSuffixError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + // Pre-occupy all possible suffixed names for a.txt with token tok_a. + // This forces relPathWithUniqueFileTokenSuffix to exhaust all attempts. + occupied := map[string]struct{}{"a.txt": {}} + // Generate the same suffixes the function would try. + tokenHash := stableTokenHash("tok_a") + suffixes := []string{ + "__lark_" + tokenHash[:12], + "__lark_" + tokenHash[:24], + "__lark_" + tokenHash, + } + for _, suffix := range suffixes { + occupied[relPathWithSuffix("a.txt", suffix)] = struct{}{} + } + for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ { + occupied[relPathWithSuffix("a.txt", "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))] = struct{}{} + } + + // Verify the function actually fails with this occupied set. + _, err := relPathWithUniqueFileTokenSuffix("a.txt", "tok_a", occupied) + if err == nil { + t.Fatal("expected relPathWithUniqueFileTokenSuffix to fail when all names are occupied") + } +} + +// TestDriveSyncKeepBothRollbackSucceedsOnPullFailure verifies the full +// keep-both rollback path: when the pull download fails after the local +// file has been renamed, the rollback restores the original file and +// the error is reported as a partial_failure. +func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "a.txt"), err: fmt.Errorf("save failed")} + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + // Diff phase: download for hash comparison. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + // Pull phase: download for keep-both pull (will fail at Save). + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Reusable: true, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected keep-both pull failure with rollback\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || !strings.Contains(items[0].Error, "save failed") { + t.Fatalf("expected save failure in item, got detail: %#v", exitErr.Detail.Detail) + } + + // Rollback should have restored the original file. + data, readErr := os.ReadFile("local/a.txt") + if readErr != nil { + t.Fatalf("ReadFile a.txt after rollback: %v", readErr) + } + if string(data) != "local-a" { + t.Fatalf("a.txt content after rollback = %q, want local-a", string(data)) + } +} + +// TestDriveSyncLocalWinsFallbackToRemoteEntriesForPush verifies that +// when remoteFile.FileToken is empty in the local-wins branch, the code +// falls back to remoteEntriesForPush to find the existing token. +func TestDriveSyncLocalWinsFallbackToRemoteEntriesForPush(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-fallback", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + // Two remote files with the same name (duplicate). Using --on-duplicate-remote=newest + // resolves to tok_new. The diff phase uses driveSyncStatusRemoteFiles which builds + // FileToken from pullRemoteFiles — but the local-wins branch reads remoteFile.FileToken + // from the status remoteFiles map. When the status map's FileToken differs from the + // push view's FileToken, the fallback to remoteEntriesForPush kicks in. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_old", "name": "a.txt", "type": "file", "created_time": "100", "modified_time": "100"}, + map[string]interface{}{"token": "tok_new", "name": "a.txt", "type": "file", "created_time": "200", "modified_time": "200"}, + }, + "has_more": false, + }, + }, + }) + // Diff phase: download tok_new (the newest duplicate) for hash comparison. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_new/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + // Upload with overwrite — the file_token in the upload should come from + // the push view's resolved duplicate (tok_new via newest strategy). + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_new", + "version": "v2", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--on-duplicate-remote", "newest", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "overwritten"`) { + t.Fatalf("expected overwritten action, got: %s", out) + } +} + +// TestDriveSyncCreatesEmptyLocalDirectoriesOnDrive verifies that empty local +// directories are created on Drive during +sync, mirroring +push behavior. +func TestDriveSyncCreatesEmptyLocalDirectoriesOnDrive(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-empty-dirs", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + // local/empty_sub/ is an empty directory — should be created on Drive. + if err := os.MkdirAll(filepath.Join("local", "empty_sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{}, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "token": "fld_empty_sub", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "folder_created"`) { + t.Fatalf("expected folder_created action for empty directory, got: %s", out) + } + if !strings.Contains(out, `"rel_path": "empty_sub"`) { + t.Fatalf("expected empty_sub in items, got: %s", out) + } +} + +// TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure verifies that +// when local-wins upload fails with a partial-success response (new +// file_token returned alongside error), the reported item uses the +// freshly returned token rather than the stale existingToken. +func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-partial-token", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + // Partial-success upload: returns a new file_token alongside an error code. + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 9999, + "msg": "partial write", + "data": map[string]interface{}{ + "file_token": "tok_a_new", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 { + t.Fatalf("expected failed item, got detail: %#v", exitErr.Detail.Detail) + } + // The reported token should be the new one from the partial-success + // response, not the stale existingToken ("tok_a"). + if items[0].FileToken != "tok_a_new" { + t.Fatalf("expected FileToken=tok_a_new from partial-success, got %q", items[0].FileToken) + } +} + +// TestDriveSyncRejectsPathTypeConflict verifies that +sync hard-fails when a +// local regular file shares a rel_path with a remote non-file entry (folder, +// docx, shortcut, etc.) instead of silently attempting to upload and leaving +// the remote in a broken mixed-type state. +func TestDriveSyncRejectsPathTypeConflict(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-type-conflict", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // Local has a regular file "report" at the same path as a remote docx. + if err := os.WriteFile("local/report", []byte("local-content"), 0o644); err != nil { + t.Fatalf("WriteFile report: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_doc", "name": "report", "type": "docx"}, + }, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected type conflict error\nstdout: %s", stdout.String()) + } + if !strings.Contains(err.Error(), "path type conflict") { + t.Fatalf("expected path type conflict error, got: %v\nstdout: %s", err, stdout.String()) + } + if !strings.Contains(err.Error(), "docx") { + t.Fatalf("error should mention remote type docx, got: %v", err) + } +} + +// TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict verifies that +sync +// hard-fails when a local directory shares a rel_path with a remote file, +// which would otherwise attempt create_folder and leave the remote in a +// broken mixed-type state. +func TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-dir-vs-file-conflict", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // Local has a directory "report" at the same path as a remote file. + if err := os.Mkdir(filepath.Join("local", "report"), 0o755); err != nil { + t.Fatalf("Mkdir report: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_file", "name": "report", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected type conflict error\nstdout: %s", stdout.String()) + } + if !strings.Contains(err.Error(), "path type conflict") { + t.Fatalf("expected path type conflict error, got: %v\nstdout: %s", err, stdout.String()) + } + if !strings.Contains(err.Error(), "local directory") { + t.Fatalf("error should mention local directory, got: %v", err) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 7edcbfd1d..452f6619c 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -25,6 +25,7 @@ func Shortcuts() []common.Shortcut { DriveStatus, DrivePush, DrivePull, + DriveSync, DriveTaskResult, DriveApplyPermission, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 2d7a6911e..158b37faf 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -28,6 +28,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+status", "+push", "+pull", + "+sync", "+task_result", "+apply-permission", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index fe55ff61c..d4ad07eef 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -245,6 +245,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+download`](references/lark-drive-download.md) | Download a file from Drive to local | | [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 | | [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. | +| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). | | [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder | | [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides | | [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming | diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index e6c140ee8..337323cbc 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -2,8 +2,8 @@ ## Metrics - Denominator: 29 leaf commands -- Covered: 7 -- Coverage: 24.1% +- Covered: 8 +- Coverage: 27.6% ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. @@ -33,6 +33,7 @@ | ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery | | ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status | | ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure | +| ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence | | ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet | | ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_upload_dryrun_test.go::TestDriveUploadDryRun_WithFileToken + drive_upload_workflow_test.go::TestDrive_UploadWorkflow + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `--file-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders; in-place overwrite uploads | dry-run covers wiki-target and overwrite request shapes; live workflows assert returned file tokens, token-stable overwrite behavior, and that uploaded fixtures are consumable by downstream commands | | ✕ | drive file.comment.replys create | api | | none | no reply workflow yet | diff --git a/tests/cli_e2e/drive/drive_sync_dryrun_test.go b/tests/cli_e2e/drive/drive_sync_dryrun_test.go new file mode 100644 index 000000000..00e70427b --- /dev/null +++ b/tests/cli_e2e/drive/drive_sync_dryrun_test.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_SyncDryRun locks in the request shape the +sync shortcut emits +// under --dry-run: the real CLI binary is invoked end-to-end, so flag +// parsing, Validate (still runs in dry-run mode), and the dry-run renderer +// all execute. The printed envelope is then inspected for GET method, +// list-files URL, the folder_token parameter, and key phrases from Desc. +// +// Fake credentials are sufficient because --dry-run short-circuits before +// any real network call. +func TestDrive_SyncDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" { + t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + desc := gjson.Get(out, "description").String() + if !strings.Contains(desc, "diff") { + t.Fatalf("description missing diff phrase, got %q\nstdout:\n%s", desc, out) + } +} + +// TestDrive_SyncDryRunRejectsAbsoluteLocalDir confirms the path validator +// runs in the real binary's Validate stage and surfaces a structured error +// referencing --local-dir. +func TestDrive_SyncDryRunRejectsAbsoluteLocalDir(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "/etc", + "--folder-token", "fldcnE2E001", + "--dry-run", + }, + WorkDir: t.TempDir(), + DefaultAs: "user", + }) + require.NoError(t, err) + if result.ExitCode == 0 { + t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout) + } + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "--local-dir") { + t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } +} + +// TestDrive_SyncDryRunRejectsMissingFolderToken confirms cobra's +// required-flag enforcement runs before our custom Validate. +func TestDrive_SyncDryRunRejectsMissingFolderToken(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + if result.ExitCode == 0 { + t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout) + } + combined := result.Stdout + "\n" + result.Stderr + if !strings.Contains(combined, "folder-token") { + t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } +} + +// TestDrive_SyncDryRunAcceptsConflictStrategies verifies that all valid +// --on-conflict values pass Validate and produce a well-formed dry-run +// envelope. +func TestDrive_SyncDryRunAcceptsConflictStrategies(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + for _, strategy := range []string{"remote-wins", "local-wins", "keep-both", "ask"} { + t.Run(strategy, func(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--on-conflict", strategy, + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + }) + } +} + +// TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies verifies that all +// valid --on-duplicate-remote values pass Validate. +func TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + for _, strategy := range []string{"fail", "newest", "oldest"} { + t.Run(strategy, func(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--on-duplicate-remote", strategy, + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + }) + } +} + +// TestDrive_SyncDryRunAcceptsQuickFlag verifies that --quick passes Validate +// and produces a well-formed dry-run envelope. +func TestDrive_SyncDryRunAcceptsQuickFlag(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--quick", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } +} diff --git a/tests/cli_e2e/drive/drive_sync_workflow_test.go b/tests/cli_e2e/drive/drive_sync_workflow_test.go new file mode 100644 index 000000000..dd4960d49 --- /dev/null +++ b/tests/cli_e2e/drive/drive_sync_workflow_test.go @@ -0,0 +1,346 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_SyncWorkflow exercises +sync against a real Drive folder, proving +// that new_remote files are pulled, new_local files are pushed, and conflicts +// are resolved according to --on-conflict. +// +// Layout (before sync): +// +// folder/ (--folder-token target) +// ├── remote-only.txt "remote" ↔ (none) → new_remote → pull +// ├── conflict.txt "remote" ↔ local: "local" → modified → resolve +// └── unchanged.txt "match" ↔ local: "match" → unchanged → skip +// local/ (--local-dir target) +// ├── local-only.txt "local" → new_local → push +// ├── conflict.txt "local" → modified → resolve +// └── unchanged.txt "match" → unchanged → skip +func TestDrive_SyncWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-"+suffix, "") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("mkdir local: %v", err) + } + + writeLocal := func(rel, content string) { + t.Helper() + full := filepath.Join(workDir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir parent of %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + uploadDriveFile := func(name, content string) string { + t.Helper() + stage := "_upload_" + name + writeLocal(stage, content) + t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) }) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+upload", + "--file", stage, + "--folder-token", folderToken, + "--name", name, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + fileToken := gjson.Get(result.Stdout, "data.file_token").String() + require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{ + Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"}, + DefaultAs: "bot", + }, clie2e.RetryOptions{}) + clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr) + }) + return fileToken + } + + // --- Subtest: remote-wins (default) --- + t.Run("remote-wins pulls new_remote and overwrites on conflict", func(t *testing.T) { + tokUnchanged := uploadDriveFile("unchanged.txt", "match") + tokConflict := uploadDriveFile("conflict.txt", "remote") + tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote") + + writeLocal("local/unchanged.txt", "match") + writeLocal("local/conflict.txt", "local") + writeLocal("local/local-only.txt", "local") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", folderToken, + "--on-conflict", "remote-wins", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + skipDriveStatusExactIfMissingDownloadScope(t, result) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + out := result.Stdout + + // Summary checks. + if got := gjson.Get(out, "data.summary.pulled").Int(); got != 2 { + t.Fatalf("pulled=%d want 2 (remote-only + conflict resolved by pull)\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "data.summary.pushed").Int(); got < 1 { + t.Fatalf("pushed=%d want >=1 (local-only)\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "data.summary.failed").Int(); got != 0 { + t.Fatalf("failed=%d want 0\nstdout:\n%s", got, out) + } + + // Item-level checks. + assertSyncItem(t, out, "downloaded", "pull", "remote-only.txt", tokRemoteOnly) + assertSyncItem(t, out, "downloaded", "pull", "conflict.txt", tokConflict) + assertSyncItem(t, out, "uploaded", "push", "local-only.txt", "") + + // Verify local file content after sync. + conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict.txt")) + if err != nil { + t.Fatalf("read conflict.txt: %v", err) + } + if string(conflictContent) != "remote" { + t.Fatalf("conflict.txt content=%q want %q", string(conflictContent), "remote") + } + require.FileExists(t, filepath.Join(workDir, "local", "remote-only.txt")) + + // Convergence: +status should now show all files as unchanged. + assertSyncConverges(t, ctx, workDir, folderToken, tokUnchanged) + }) + + // --- Subtest: local-wins --- + t.Run("local-wins pushes new_local and overwrites remote on conflict", func(t *testing.T) { + tokConflict := uploadDriveFile("conflict-lw.txt", "remote") + _ = uploadDriveFile("remote-only-lw.txt", "remote") + + writeLocal("local/conflict-lw.txt", "local-wins") + writeLocal("local/local-only-lw.txt", "local") + writeLocal("local/remote-only-lw.txt", "already-here") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", folderToken, + "--on-conflict", "local-wins", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + skipDriveStatusExactIfMissingDownloadScope(t, result) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + out := result.Stdout + + // Conflict file should be overwritten with local version. + assertSyncItem(t, out, "overwritten", "push", "conflict-lw.txt", tokConflict) + + // Verify local content is unchanged (local-wins). + conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-lw.txt")) + if err != nil { + t.Fatalf("read conflict-lw.txt: %v", err) + } + if string(conflictContent) != "local-wins" { + t.Fatalf("conflict-lw.txt content=%q want %q", string(conflictContent), "local-wins") + } + }) + + // --- Subtest: keep-both --- + t.Run("keep-both renames local and pulls remote", func(t *testing.T) { + uploadDriveFile("conflict-kb.txt", "remote-kb") + + writeLocal("local/conflict-kb.txt", "local-kb") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", folderToken, + "--on-conflict", "keep-both", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + skipDriveStatusExactIfMissingDownloadScope(t, result) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + out := result.Stdout + + // Should have a renamed_local item and a downloaded item. + assertSyncItem(t, out, "renamed_local", "conflict", "conflict-kb.txt", "") + assertSyncItem(t, out, "downloaded", "pull", "conflict-kb.txt", "") + + // Original path now has remote content. + origContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-kb.txt")) + if err != nil { + t.Fatalf("read conflict-kb.txt: %v", err) + } + if string(origContent) != "remote-kb" { + t.Fatalf("conflict-kb.txt content=%q want %q", string(origContent), "remote-kb") + } + + // A suffixed sibling should exist with the local content. + entries, err := os.ReadDir(filepath.Join(workDir, "local")) + if err != nil { + t.Fatalf("readdir local: %v", err) + } + var foundSuffixed bool + for _, e := range entries { + if strings.HasPrefix(e.Name(), "conflict-kb__lark_") && strings.HasSuffix(e.Name(), ".txt") { + foundSuffixed = true + suffixedContent, readErr := os.ReadFile(filepath.Join(workDir, "local", e.Name())) + if readErr != nil { + t.Fatalf("read suffixed file: %v", readErr) + } + if string(suffixedContent) != "local-kb" { + t.Fatalf("suffixed file content=%q want %q", string(suffixedContent), "local-kb") + } + break + } + } + if !foundSuffixed { + t.Fatalf("expected suffixed sibling conflict-kb__lark_*.txt, entries: %v", entries) + } + }) +} + +// TestDrive_SyncEmptyDirWorkflow proves that empty local directories are +// created on Drive during +sync, and that a subsequent +status converges. +func TestDrive_SyncEmptyDirWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-emptydir-"+suffix, "") + + workDir := t.TempDir() + // Create an empty subdirectory under local. + if err := os.MkdirAll(filepath.Join(workDir, "local", "empty_sub"), 0o755); err != nil { + t.Fatalf("mkdir local/empty_sub: %v", err) + } + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+sync", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + out := result.Stdout + // Should report folder_created for the empty directory. + if !strings.Contains(out, `"action": "folder_created"`) { + t.Fatalf("expected folder_created action for empty directory, got:\n%s", out) + } + if !strings.Contains(out, `"empty_sub"`) { + t.Fatalf("expected empty_sub in items, got:\n%s", out) + } +} + +// assertSyncItem checks that a sync item with the given action, direction, +// and rel_path exists in the output. If fileToken is non-empty, it also +// verifies the item carries that token. +func assertSyncItem(t *testing.T, stdout, action, direction, relPath, fileToken string) { + t.Helper() + items := gjson.Get(stdout, "data.items") + if !items.IsArray() { + t.Fatalf("data.items is not an array\nstdout:\n%s", stdout) + } + var found bool + items.ForEach(func(_, item gjson.Result) bool { + if item.Get("action").String() != action || item.Get("direction").String() != direction || item.Get("rel_path").String() != relPath { + return true + } + found = true + if fileToken != "" { + if got := item.Get("file_token").String(); got != fileToken { + t.Errorf("item %s/%s/%s file_token=%q want %q", action, direction, relPath, got, fileToken) + } + } + return false + }) + if !found { + t.Fatalf("missing sync item action=%s direction=%s rel_path=%s\nstdout:\n%s", action, direction, relPath, stdout) + } +} + +// assertSyncConverges runs +status after a sync and asserts that all shared +// files are unchanged (i.e. the mirror has converged). +func assertSyncConverges(t *testing.T, ctx context.Context, workDir, folderToken, unchangedToken string) { + t.Helper() + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + skipDriveStatusExactIfMissingDownloadScope(t, result) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + out := result.Stdout + if got := gjson.Get(out, "data.modified.#").Int(); got != 0 { + t.Fatalf("post-sync +status modified=%d want 0\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "data.new_local.#").Int(); got != 0 { + t.Fatalf("post-sync +status new_local=%d want 0\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "data.new_remote.#").Int(); got != 0 { + t.Fatalf("post-sync +status new_remote=%d want 0\nstdout:\n%s", got, out) + } + if unchangedToken != "" { + assertStatusBucketEntry(t, out, "unchanged", "unchanged.txt", unchangedToken) + } +} From 7af616b9e5fad657f7a273d1b4953c06528bc290 Mon Sep 17 00:00:00 2001 From: JackZhao10086 Date: Mon, 18 May 2026 20:17:15 +0800 Subject: [PATCH 15/61] feat(auth): add QR code support for device auth flow (#942) * feat(auth): add QR code support for device auth flow * docs: update login QR code display hints for AI agent * feat(auth): add ASCII QR code support for auth flow * docs: add comments for login and auth helper functions * chore: remove unused qrCodeToBase64 helper function * fix(auth/login): clarify verification_url handling in login hint --- cmd/auth/login.go | 49 ++++++++++++++++++++++++--- cmd/auth/login_messages.go | 69 +++++++++++++++++++++++++------------- 2 files changed, 89 insertions(+), 29 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 02888c98e..1d0e28e32 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,12 +5,14 @@ package auth import ( "context" + "encoding/base64" "encoding/json" "fmt" "sort" "strings" "time" + qrcode "github.com/skip2/go-qrcode" "github.com/spf13/cobra" larkauth "github.com/larksuite/cli/internal/auth" @@ -265,11 +267,15 @@ func authLoginRun(opts *LoginOptions) error { if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil { fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err) } + qrCodeASCII, qrCodeBase64 := generateQRCode(authResp.VerificationUriComplete) data := map[string]interface{}{ - "verification_url": authResp.VerificationUriComplete, - "device_code": authResp.DeviceCode, - "expires_in": authResp.ExpiresIn, - "hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode), + "qr_code_ascii": qrCodeASCII, + "qr_code_base64": qrCodeBase64, + "qr_code_display_hint": msg.QRCodeDisplayHint, + "verification_url": authResp.VerificationUriComplete, + "device_code": authResp.DeviceCode, + "expires_in": authResp.ExpiresIn, + "hint": fmt.Sprintf("Show qr_code_base64 as an image and verification_url exactly as returned by the CLI to the user. If your agent cannot display images, show qr_code_ascii (ASCII QR code) or verification_url instead. Treat verification_url as an opaque string: Do not URL-encode or decode it, do not normalize, rewrite, do not add %%20, spaces, or punctuation, do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the image (if displayable) and verification_url the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode), } encoder := json.NewEncoder(f.IOStreams.Out) encoder.SetEscapeHTML(false) @@ -285,8 +291,12 @@ func authLoginRun(opts *LoginOptions) error { // stdout into a JSON parser sees it without stream-mixing surprises), // text mode prints to stderr (alongside the URL prompt). if opts.JSON { + qrCodeASCII, qrCodeBase64 := generateQRCode(authResp.VerificationUriComplete) data := map[string]interface{}{ "event": "device_authorization", + "qr_code_ascii": qrCodeASCII, + "qr_code_base64": qrCodeBase64, + "qr_code_display_hint": msg.QRCodeDisplayHint, "verification_uri": authResp.VerificationUri, "verification_uri_complete": authResp.VerificationUriComplete, "user_code": authResp.UserCode, @@ -299,7 +309,21 @@ func authLoginRun(opts *LoginOptions) error { return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err) } } else { - fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) + // Branch on TTY: human-friendly copy in interactive terminals, + // For non-TTY (AI agent callers), output text with both ASCII and base64 QR code. + fmt.Fprintf(f.IOStreams.ErrOut, msg.ScanQRCode) + qrCodeASCII, qrCodeBase64 := generateQRCode(authResp.VerificationUriComplete) + fmt.Fprint(f.IOStreams.ErrOut, qrCodeASCII) + if !f.IOStreams.IsTerminal { + if qrCodeBase64 != "" { + fmt.Fprintf(f.IOStreams.ErrOut, "[BASE64 QR CODE START]\n") + fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", qrCodeBase64) + fmt.Fprintf(f.IOStreams.ErrOut, "[BASE64 QR CODE END]\n") + fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.QRCodeDisplayHint) + } + } + fmt.Fprintln(f.IOStreams.ErrOut) + fmt.Fprintf(f.IOStreams.ErrOut, msg.ScanOrOpenLink) fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete) fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint) } @@ -452,6 +476,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo return nil } +// syncLoginUserToProfile updates the profile's user list to contain only the newly +// authenticated user, removing any previously stored tokens for other users. func syncLoginUserToProfile(profileName, appID, openID, userName string) error { multi, err := core.LoadMultiAppConfig() if err != nil { @@ -477,6 +503,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error { return nil } +// findProfileByName locates an AppConfig by profile name from the multi-app configuration. func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig { for i := range multi.Apps { if multi.Apps[i].ProfileName() == profileName { @@ -668,3 +695,15 @@ func applyExcludeScopes(requested string, excludes []string) (string, []string) } return joinSortedScopeSet(kept), nil } + +// generateQRCode creates both ASCII art and base64-encoded PNG versions of a QR code +// for the given verification URL. Returns empty strings if generation fails. +func generateQRCode(verificationURL string) (ascii string, base64Str string) { + if qr, err := qrcode.New(verificationURL, qrcode.Medium); err == nil { + ascii = qr.ToSmallString(true) + if pngBytes, err := qr.PNG(256); err == nil { + base64Str = base64.StdEncoding.EncodeToString(pngBytes) + } + } + return +} diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 189c42740..5eb9b7918 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -23,6 +23,7 @@ type loginMsg struct { OpenURL string WaitingAuth string AgentTimeoutHint string + AgentNoJSONHint string AuthSuccess string LoginSuccess string AuthorizedUser string @@ -32,6 +33,12 @@ type loginMsg struct { NewlyGrantedScopes string NoScopes string StatusHint string + // QR code messages + ScanQRCode string // header shown above QR code (TTY) + ScanOrOpenLink string // post-QR alt link prompt ("or open...") (TTY) + OpenLinkNonTTY string // primary link prompt (non-TTY/AI) + WaitingForScanNonTTY string // passive waiting indicator (non-TTY/AI) + QRCodeDisplayHint string // hint for displaying qr_code_base64 // Non-interactive hint (no flags) HintHeader string @@ -57,18 +64,24 @@ var loginMsgZh = &loginMsg{ ErrNoDomain: "请至少选择一个业务域", ConfirmAuth: "确认授权?", - OpenURL: "在浏览器中打开以下链接进行认证:\n\n", - WaitingAuth: "等待用户授权...", - AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code ` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。", - AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...", - LoginSuccess: "授权成功! 用户: %s (%s)", - AuthorizedUser: "当前授权账号: %s (%s)", - ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s", - ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", - RequestedScopes: " 本次请求 scopes: %s\n", - NewlyGrantedScopes: " 本次新授予 scopes: %s\n", - NoScopes: "(空)", - StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + OpenURL: "在浏览器中打开以下链接进行认证:\n\n", + WaitingAuth: "等待用户授权...", + AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code、qr_code_base64 和 verification_url,把二维码图片(解码 qr_code_base64)和 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code ` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。若你的 agent 无法显示图片,只展示 verification_url 即可。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。", + AgentNoJSONHint: "[AI agent] 如需获取二维码图片,请添加 --json 或使用 `lark-cli auth login --no-wait --json` 获取 qr_code_base64 字段。", + AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...", + LoginSuccess: "授权成功! 用户: %s (%s)", + AuthorizedUser: "当前授权账号: %s (%s)", + ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s", + ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + RequestedScopes: " 本次请求 scopes: %s\n", + NewlyGrantedScopes: " 本次新授予 scopes: %s\n", + NoScopes: "(空)", + StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;", + ScanQRCode: "\n使用飞书 / Lark 扫码授权:\n\n", + ScanOrOpenLink: "\n或打开以下链接完成授权:\n", + OpenLinkNonTTY: "\n打开以下链接授权:\n\n", + WaitingForScanNonTTY: "等待授权...", + QRCodeDisplayHint: "[AI Agent] 请尽可能展示 qr_code_base64 图片!qr_code_base64 包含 base64 编码的 PNG 图片,解码后以图片形式显示(256x256 像素)便于用户扫码授权。如果 agent 无法显示图片,可以展示 qr_code_ascii(ASCII 二维码),并且展示 verification_url 让用户可以手动打开。", HintHeader: "请指定要授权的权限:\n", HintCommon1: " --recommend 授权推荐权限", @@ -93,18 +106,24 @@ var loginMsgEn = &loginMsg{ ErrNoDomain: "please select at least one domain", ConfirmAuth: "Confirm authorization?", - OpenURL: "Open this URL in your browser to authenticate:\n\n", - WaitingAuth: "Waiting for user authorization...", - AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code ` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.", - AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...", - LoginSuccess: "Authorization successful! User: %s (%s)", - AuthorizedUser: "Authorized account: %s (%s)", - ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s", - ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.", - RequestedScopes: " Requested scopes: %s\n", - NewlyGrantedScopes: " Newly granted scopes: %s\n", - NoScopes: "(none)", - StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.", + OpenURL: "Open this URL in your browser to authenticate:\n\n", + WaitingAuth: "Waiting for user authorization...", + AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code, qr_code_base64 and verification_url, present the QR code image (decode qr_code_base64) and verification_url to the user as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code ` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. If your agent cannot display images, show verification_url only. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.", + AgentNoJSONHint: "[AI agent] To get QR code image, add --json or use `lark-cli auth login --no-wait --json` to get the qr_code_base64 field.", + AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...", + LoginSuccess: "Authorization successful! User: %s (%s)", + AuthorizedUser: "Authorized account: %s (%s)", + ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s", + ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.", + RequestedScopes: " Requested scopes: %s\n", + NewlyGrantedScopes: " Newly granted scopes: %s\n", + NoScopes: "(none)", + StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.", + ScanQRCode: "\nScan the QR code with Feishu/Lark to authorize:\n\n", + ScanOrOpenLink: "\nOr open the link below to complete authorization:\n", + OpenLinkNonTTY: "\nOpen the link below to authorize:\n\n", + WaitingForScanNonTTY: "Waiting for authorization...", + QRCodeDisplayHint: "[AI Agent] Please display qr_code_base64 image if at all possible! qr_code_base64 contains a base64-encoded PNG image. Decode it and display as an image (256x256 pixels) for easy QR code scanning. If your agent cannot display images, you can show qr_code_ascii (ASCII QR code) and display verification_url for users to open manually.", HintHeader: "Please specify the scopes to authorize:\n", HintCommon1: " --recommend authorize recommended scopes", @@ -114,6 +133,8 @@ var loginMsgEn = &loginMsg{ HintFooter: " lark-cli auth login --help", } +// getLoginMsg returns the login message bundle for the specified language. +// Supports "zh" for Chinese and "en" for English. Defaults to Chinese. func getLoginMsg(lang string) *loginMsg { if lang == "en" { return loginMsgEn From 67b16c5ec384c3605abc9affcfb7b2430684789d Mon Sep 17 00:00:00 2001 From: ethan-zhx Date: Mon, 18 May 2026 20:44:50 +0800 Subject: [PATCH 16/61] feat(slides): improve slide planning and validation guidance (#847) refactor(slides): rename slide layout lint scope Change-Id: I1b0e42b6508ec2c5f6ae6dc0d1b7ac23c5bbe2e3 feat(slides): improve lark slides skill guidance Change-Id: I49563da4ca623a89f5391f36ceb8f5a31417e321 feat(slides): strengthen lark slides planning guidance Change-Id: If49330e1f9b779bc76a919565ed61a31c255f508 feat(slides): remove lark slides layout lint rules Change-Id: I64f1fc3b33d05c069c9ef58e61d00aa57ac18ecd refactor(slides): streamline skill guidance Change-Id: I3b39faaab7dcac52fac1572590fc5d8934428da5 feat(slides): add slides asset planning guidance Change-Id: I37303043f7704e4ba484552158390a4e24bf9c42 feat(slides): add visual planning guidance Change-Id: Idee7c392d41ff02124313d572c547d0a086d9c35 feat(slides): add lark slides planning layer Change-Id: I3f0765aa53656070d9ba9b388dade19355e7bc6f --- .gitignore | 1 + skills/lark-slides/SKILL.md | 431 ++++-------------- .../lark-slides/references/asset-planning.md | 124 +++++ ...rk-slides-xml-presentation-slide-create.md | 5 +- .../lark-slides/references/planning-layer.md | 219 +++++++++ .../references/template-catalog.md | 1 - .../lark-slides/references/troubleshooting.md | 63 +++ .../references/validation-checklist.md | 102 +++++ .../lark-slides/references/visual-planning.md | 250 ++++++++++ ...ayout_lint.py => xml_text_overlap_lint.py} | 182 ++++---- ..._test.py => xml_text_overlap_lint_test.py} | 140 +++++- 11 files changed, 1084 insertions(+), 434 deletions(-) create mode 100644 skills/lark-slides/references/asset-planning.md create mode 100644 skills/lark-slides/references/planning-layer.md create mode 100644 skills/lark-slides/references/troubleshooting.md create mode 100644 skills/lark-slides/references/validation-checklist.md create mode 100644 skills/lark-slides/references/visual-planning.md rename skills/lark-slides/scripts/{layout_lint.py => xml_text_overlap_lint.py} (70%) rename skills/lark-slides/scripts/{layout_lint_test.py => xml_text_overlap_lint_test.py} (51%) diff --git a/.gitignore b/.gitignore index dc576a2c8..337ba9844 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ tests/mail/reports/ # Generated / test artifacts .hammer/ +.lark-slides/ internal/registry/meta_data.json cmd/api/download.bin app.log diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 095a95f25..3adc795ec 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -10,16 +10,38 @@ metadata: # slides (v1) +## Quick Reference + +| 用户需求 | 优先动作 | 关键文档 / 命令 | +|----------|----------|-----------------| +| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` | +| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` | +| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` | +| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` | +| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 | +| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` | +| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` | + **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** **CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。** +**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan//slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。** + +**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。** + +**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。** + +**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。** + +**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。** + **CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。** > [!NOTE] > `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。 -**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。生成本地 XML 后,如可运行 Python,MUST 先用 [`scripts/layout_lint.py`](scripts/layout_lint.py) 检查 XML well-formed、重叠/越界/文本高度风险,再创建或追加页面。它不是完整 XSD schema 校验。** +**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。** **编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。 @@ -41,54 +63,68 @@ lark-cli auth login --domain slides 2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。 3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。 -## 快速开始 +## 执行前必做 -一条命令创建包含页面内容的 PPT(推荐): +> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。 -```bash -lark-cli slides +create --title "演示文稿标题" --slides '[ - "

页面标题

正文内容

  • 要点一

  • 要点二

" -]' -``` +高频只读: -也可以分两步(先创建空白 PPT,再逐页添加),详见 [+create 参考文档](references/lark-slides-create.md)。 +- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) +- [planning-layer.md](references/planning-layer.md)(新建 / 大幅改写) +- [visual-planning.md](references/visual-planning.md)(新建 / 大幅改写) +- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写) +- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后) -> [!WARNING] -> `--slides '[...]'` 适合简单页面批量创建,但并不等同于“10 页以内都安全”。如果 slide XML 含中文、大段文本、复杂布局、嵌套引号或较多特殊字符,shell 传参时可能出现转义或截断问题,导致内容丢失、页面空白或布局异常。遇到复杂页面时,优先改用“两步创建法”。 +按需再读: -> [!IMPORTANT] -> `slides +create --slides` 底层是“先创建空白 PPT,再逐页调用 `xml_presentation.slide.create`”。这不是原子操作;中途某一页失败时,前面已创建成功的页面会保留。skill 必须把这种“部分成功”风险提前告诉用户,并在失败后先记录 `xml_presentation_id`,回读确认当前状态,再决定是否在现有 PPT 上继续修复或追加。 +- 创建:[`lark-slides-create.md`](references/lark-slides-create.md) +- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md) +- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md) +- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py) +- 排障:[`troubleshooting.md`](references/troubleshooting.md) +- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml) -> 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。 +## Workflow -## 执行前必做 +> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。 -> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。 +### Design Ideas -### 必读(每次创建前) +不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。 -| 文档 | 说明 | -|------|------| -| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML 元素和属性速查,必读** | +开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略: -### 选读(需要时查阅) +- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。 +- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重,1-2 个辅助色承担结构和分区,1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。 +- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。 +- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。 -| 场景 | 文档 | -|------|------| -| 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) | -| 需要快速筛模板、做低成本路由 | [`scripts/template_tool.py search`](scripts/template_tool.py) | -| 需要匹配 PPT 模板/主题风格 | [template-catalog.md](references/template-catalog.md) | -| 需要按页型抽摘要或裁切 XML 片段 | [`scripts/template_tool.py`](scripts/template_tool.py) | -| 需要做本地布局风险检查 | [`scripts/layout_lint.py`](scripts/layout_lint.py) | -| 需要 CLI 调用示例 | [examples.md](references/examples.md) | -| 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) | -| 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema) | -| 需要编辑已有 PPT 的单个页面 | [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | -| 需要了解某个命令的详细参数 | 对应命令的 reference 文档(见下方参考文档章节) | +每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。 -## Workflow +可优先考虑这些页面形态: -> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。 +- **双栏结构**:左文右图或左图右文,视觉区域占 35-45% 宽度。 +- **图标行**:图标在色块或圆形底中,右侧是短标题和一句解释。 +- **2x2 / 2x3 网格**:适合能力、模块、风险、行动项,每格内容保持同等层级。 +- **半出血视觉**:图片或抽象形状占据左/右半屏,文字覆盖或贴边排布。 +- **大数字卡片**:关键指标用 60-72pt 数字,下面配 10-14pt 标签。 +- **对比列**:before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。 +- **时间线/流程图**:步骤用节点和箭头表达,流程方向必须一眼可见。 + +字体和间距建议: + +- 标题 36-44pt,关键结论可更大;正文 14-18pt;注释 10-12pt。 +- 正文默认左对齐;只在封面、结尾或大号数字场景中使用居中。 +- 页面边距至少 40px;内容块之间保持 24-40px 间距,并在同一 deck 内保持一致。 +- 卡片内边距要真实留出空间,不要让文字贴边;对齐 shape 和文字时要考虑文本框 padding。 + +常见错误必须避免: + +- 不要所有页面复用同一种标题 + 三 bullets 版式。 +- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。 +- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。 +- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。 +- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。 ### 创建方式选择 @@ -101,156 +137,40 @@ lark-cli slides +create --title "演示文稿标题" --slides '[ > [!WARNING] > `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。 +> [!IMPORTANT] +> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。 + ### 模板与脚本优先流程 +模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。 + ```bash -# 1. 搜索候选:把用户原始需求整句放进 --query,不要只放手动提炼的短词 python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3 - -# 2. 锁定模板后先看页型摘要 python3 skills/lark-slides/scripts/template_tool.py summarize --template --label <封面|目录|分节|内容|结尾> - -# 3. 只有需要复用布局骨架时才裁切 XML python3 skills/lark-slides/scripts/template_tool.py extract --template --label <页型> --out /tmp/template-slice.xml - -# 4. 生成待创建 XML 后先做布局风险检查 -python3 skills/lark-slides/scripts/layout_lint.py --input /tmp/presentation.xml ``` -执行规则: - -1. `search --query` 使用用户原始描述;如用户明确风格,再额外加 `--tone light|dark|colorful` 或 `--formality formal|casual|creative`。 -2. 候选展示只给 2-3 个,包含模板名、适用场景、风格/色调、推荐理由;不要把完整目录贴给用户。 -3. 锁定模板后,复用 ``、配色、页面流、布局骨架;所有占位文案都必须改写为用户真实内容。 -4. `layout_lint.py` 有 error 时先修 XML,不要提交创建;只有 warning 时,检查是否是可接受的装饰/背景误报。 - ```text Step 1: 需求澄清 & 读取知识 - - 澄清用户需求:主题、受众、页数、风格偏好 - - 如果需求明显落在已有模板场景内,主动提示用户“可以直接基于现成模板生成”,并给出 2-3 个最匹配模板候选(模板名 + 适用场景 + 风格/色调 + 简短推荐理由) - - 默认不要把完整模板目录直接贴给用户;除非用户明确要求看更多,否则只展示 2-3 个候选 - - 候选优先选场景强相关模板;只有没有明显场景模板时,才用 `light_general.xml` / `dark_general.xml` 这类通用模板兜底 - - 如果用户没有明确风格,根据主题推荐(见下方风格判断表) - - 如果用户要求“模板/主题/风格参考”,或主题属于常见模板场景: - · 优先运行 `python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3` 做低成本模板匹配 - · 需要人类可读说明时,再读 template-catalog.md 组织候选文案 - · 锁定模板后,优先运行 `template_tool.py summarize` 看 `` / 页型摘要;需要具体布局时,再用 `template_tool.py extract` - · 复用模板的 theme、配色、页面流、布局骨架,不要照搬占位文案 - · `references/template-index.json` 只是脚本缓存/轻量路由索引,`assets/templates/*.xml` 是机器资源;除非用户明确要求审计原始模板,否则不要直接读取 - - 读取 XML Schema 参考: - · xml-schema-quick-ref.md — 元素和属性速查 - · xml-format-guide.md — 详细结构与示例 - · slides_demo.xml — 真实 XML 示例 - -Step 2: 生成大纲 → 用户确认 → 创建 - - 生成大纲前,先确认用户是否采用推荐模板;轻量任务且候选中有明显最佳匹配时,可在大纲里声明“默认基于 改写”并继续,但正式创建前必须给用户改选机会 - - 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认 - - 如果已选模板,大纲和页面布局要明确标注“基于哪个模板/哪些模板改写” - - 如果用户明确不要模板,直接按自定义风格继续,不要重复推动模板选择 - - 先判断创建方式: - · 简单 XML:可用 `slides +create --slides '[...]'` 一步创建 - · 复杂 XML:优先先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加 - · 超过 10 页:默认使用两步创建,避免单次输入过长 - - 含本地图片: - · 新建带图 PPT —— 在 slide XML 里写 , - +create 会自动上传并替换为 file_token(详见 lark-slides-create.md) - · 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID` - 拿到 file_token,再用它写进 slide XML 调 xml_presentation.slide.create - · 给已有页加图 —— 两步:① `slides +media-upload` 拿 file_token - ② `slides +replace-slide --parts '[{"action":"block_insert","insertion":"\" .../>"}]'` - 不动其他元素,不要再整页重建(完整示例见 lark-slides-edit-workflows.md 的 block_insert 章节) - · 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png); - 绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行 - - 每页 slide 需要完整的 XML:背景、文本、图形、配色 - - 复杂元素(table、chart)需参考 XSD 原文 - - 创建前必须做 XML 自检: - · 检查特殊字符是否按 XML 规则转义:文本节点和属性值里的裸 `& -> &`;文本里的 `< -> <`、`> -> >`。例如 `Q&A -> Q&A`,URL 属性 `a=1&b=2 -> a=1&b=2` - · 属性值里的双引号必须转义或改为外层安全包装,避免 shell 和 JSON 双重截断 - · 确认所有标签闭合,且 `` 直接子元素只包含 ` + +
+ +
+

[调研主题] 市场调研报告

+

[YYYY-MM-DD] | 调研者:[姓名] · [团队] | [关联系统 / 版本]

+
+ +
+

调研背景

+

[一段话描述:本轮调研聚焦的赛道 / 行业背景 / 触发动机]。本轮调研覆盖 [N] 类玩家([类别 1] / [类别 2] / [类别 3] / [类别 4]),重点评估 [自家产品 / 团队] 在 [赛道名] 的位置、对外摩擦点,以及结合 [关联工作 / PR / 本期目标] 的待补能力。所有结论基于 [数据来源 1:公开资料 / 厂商文档 / 行业报告] + [数据来源 2:自有实测 / 内部调研笔记] + [数据来源 3:访谈 / 体验]。

+
+ +
+
+
[N]
+
调研对象
+
+
+
[N]
+
已就绪能力
+
+
+
[N]
+
明确缺口
+
+
+
[N]
+
高优待办
+
+
+ +
+

1. [章节标题:例 "全球市场态势"]

+

[一句话描述本节切分维度,例 "把市场按 '为谁设计' 切四象限"]

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
玩家 / 对象定位 / 类型[关键评分维度]关键观察
[玩家 1][类别][标签][一句话观察]
[玩家 2][类别][标签][一句话观察]
[玩家 3][类别][标签][一句话观察]
[玩家 4][类别][标签][一句话观察]
+ + +

+ +
+

3. [章节标题:例 "新势力玩家详情" / "重点对象详细比较"]

+
+
+
[玩家 / 对象 1]
+
[一句话产品定位 / 核心能力 / 差异化]
+
关键差异:[一句话提炼]
+
+
+
[玩家 / 对象 2]
+
[产品定位]
+
关键差异:[一句话]
+
+
+
[玩家 / 对象 3]
+
[产品定位]
+
关键差异:[一句话]
+
+
+

[小结一句话:玩家共性 / 自家路线对比]

+
+ +
+

4. [章节标题:例 "安全风险全景" / "潜在隐患"] ⚠️ 高危

+

[一句话描述:风险来源 / 关联前期工作]

+ + + + + + + + + + + + + + + + + + + + + + + +
威胁 / 风险案例 / 来源自家现状
[风险 1][案例 / 来源链接 / 引用前期报告][标签]
[风险 2][案例 / 来源][标签]
[风险 3](重点)[案例 / 来源][标签]
+
+ 结论:[一段话,提炼本章节最关键的判断 / 行动建议] +
+
+ +
+

5. [章节标题:例 "自家已就绪能力"] ✓ 优势

+

[一句话描述:基于哪些 PR / 已交付的工作得出]

+
    +
  • [能力 1] — [简述 + 关联 PR / 文档链接]
  • +
  • [能力 2] — [简述]
  • +
  • [能力 3] — [简述]
  • +
  • [能力 4] — [简述]
  • +
+
+ +
+

6. [章节标题:例 "待补能力 / 机会清单"]

+

[一句话描述:清单口径 / 优先级判定依据]

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#优先级能力 / 缺口建议落地
1P0[能力 / 缺口 1][具体落地路径 / Owner / 估算]
2P0[能力 / 缺口 2][具体落地路径]
3P1[能力 / 缺口 3][具体落地路径]
4P1[能力 / 缺口 4][具体落地路径]
5P2[能力 / 缺口 5][具体落地路径]
+
+ +
+ +
+

建议与下一步

+
    +
  1. [行动 1] — [具体路径 + 时间窗 + Owner]
  2. +
  3. [行动 2] — [具体路径 + 时间窗]
  4. +
  5. [行动 3] — [具体路径]
  6. +
  7. [行动 4] — [具体路径]
  8. +
+
+ +
+

调研者:[your@email] · [团队]|整合于 [YYYY-MM-DD]

+

关联材料:[文档 / 笔记路径 / 前期报告]

+
+ + diff --git a/skills/lark-mail/assets/templates/weekly--personal-report.html b/skills/lark-mail/assets/templates/weekly--personal-report.html new file mode 100644 index 000000000..717b1cac5 --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--personal-report.html @@ -0,0 +1,43 @@ + +
[姓名] 个人工作周报 · [YYYY 第 NN 周]
+
[团队] · [角色]|周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]
+ +
本周工作内容
+ +
1. [项目 / 主任务名称]已完成 · 📄 文档 · PR 链接
+
  • [子项 1.1:动作描述,附数据 / 链接]
  • [子项 1.2:动作描述]
  • [子项 1.3:动作描述,含具体数字 / 占比 / 时长]
+ +
2. [项目 / 主任务名称]进行中 · 📄 文档
+
  • [子项 2.1:动作 + 当前进度 + 数据]
  • [子项 2.2:动作 + 当前进度]
+ +
3. [项目 / 主任务名称]已完成
+
  • [子项 3.1]
  • [子项 3.2]
+ +
下周工作内容
+ +
1. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
+
  • [子项 1.1:具体动作 + 推进方式,例「先 spike POC,再发 RFC 同协作方对齐方案」]
  • [子项 1.2:里程碑 / 关键产出 + 完成方式]
  • [子项 1.3:依赖 / 协作方 / 验收标准]
+ +
2. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
+
  • [子项 2.1:动作 + 推进方式]
  • [子项 2.2:里程碑 / 关键产出]
  • [子项 2.3:依赖 / 验收]
+ +
3. [项目 / 主任务名称]P1 · 预计 [YYYY-MM-DD]
+
  • [子项 3.1:动作 + 推进方式]
  • [子项 3.2:里程碑]
  • [子项 3.3:协作方]
+ +
4. [项目 / 主任务名称]P2 · 预计 [YYYY-MM-DD]
+
  • [子项 4.1:动作 + 推进方式]
  • [子项 4.2:依赖 / 关键产出]
+ +
风险与疑问
+
  • [风险 / 疑问 1] — [背景:描述风险来源 / 触发场景];[影响:会延期 / 阻塞哪些工作];[建议:希望得到的支持 / 决策方向 / 期望响应方(@姓名 / 团队)]
  • [风险 / 疑问 2] — [背景];[影响];[建议]
  • [风险 / 疑问 3] — [背景];[影响];[建议]
+
(若本周无风险 / 疑问,整段替换为:。)
+ +
— [姓名] / [团队] / [日期]|[your@email]
diff --git a/skills/lark-mail/assets/templates/weekly--team-report.html b/skills/lark-mail/assets/templates/weekly--team-report.html new file mode 100644 index 000000000..893d3f18a --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--team-report.html @@ -0,0 +1,19 @@ + +
本周工作
+
  1. [项目 / 事件 1 名称]@[姓名 a]@[姓名 b]
+
文档:[文档名]
+ +
  1. [项目 / 事件 2 名称]@[姓名 g]
+
技术方案:[文档名] · 设计稿:[设计稿名]
+
    • [子项 2.1:含孙子项的动作主题]
      • [孙子项 2.1.1:必要时再细分一层;不需要可整段删除]@[姓名 h]
      • [孙子项 2.1.2]
    • [子项 2.2]@[姓名 i],进行中
    • [子项 2.3]@[姓名 j],评审中
+
  1. [项目 / 事件 3 名称]@[姓名 k]@[姓名 l]阻塞
+
阻塞分析:[文档名]
+ +
下周工作
+
  1. [重点 1:项目 / 事件名]@[姓名 o],预计 [YYYY-MM-DD]
  2. [重点 2:含子重点的项目]
+
    1. [子重点 a:动作 / 推进方式]@[姓名 p]
    2. [子重点 b:动作]@[姓名 q]
+
  1. [重点 3:项目 / 事件名]@[姓名 r]@[姓名 s],预计 [YYYY-MM-DD]
  2. [重点 4:项目 / 事件名]@[姓名 t],预计 [YYYY-MM-DD]
+
— [姓名] / [团队] / [日期]|[your@email]
diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index eeb016af9..a64e77aa4 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -8,6 +8,8 @@ 如需修改已有草稿,不要使用此命令,请使用 `lark-cli mail +draft-edit`。 +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** + ## 安全约束 此命令创建草稿——**不会**发送邮件。用户可以在飞书邮件 UI 中打开草稿查看详情,确认后再进入后续操作。因此: diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 366c5cf82..76e84e794 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -12,6 +12,8 @@ **正文编辑和其他高级操作必须通过 `--patch-file`**。没有 `--set-body` flag。 +**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** + ### 正文编辑:两个 op 的选择 正文编辑通过 `--patch-file` 传入,有两个 op 可选: diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index e2e50830c..8530c245e 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -13,6 +13,8 @@ ## CRITICAL — 发送工作流(必须遵循) +编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范** + 此命令默认**只保存草稿**,不会发送邮件。转发会将原邮件内容发送给新收件人,需要发送时有两种合规方式: **方式 A(推荐)** — 创建转发草稿(不带 `--confirm-send`): diff --git a/skills/lark-mail/references/lark-mail-html.md b/skills/lark-mail/references/lark-mail-html.md new file mode 100644 index 000000000..1b97b8a0f --- /dev/null +++ b/skills/lark-mail/references/lark-mail-html.md @@ -0,0 +1,311 @@ +# 邮件 HTML 写法指南 + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解通用安全规则。本文档定义 lark-cli mail 写信场景下的 HTML / CSS / URL 写法、LarkSuite mail-editor 原生格式、可复制片段、3 套场景模板。 + +**CRITICAL 邮件是重要的对外交流渠道,请你保证书写语言凝练扼要** +**CRITICAL 电子邮件的 HTML 不是 Web 开发的 HTML,请你务必遵守本文档中提及的常用邮件格式书写规范** +**CRITICAL 请务必使用 shortcut 来进行邮件内容编辑 (`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward`)或 `+draft-edit` 的 body op,严禁自行拼接 EML** + +你可以参考 **官方模板库** [`../assets/templates/`](../assets/templates) — 提供部分场景模板,可供参考 + +> 请注意,邮件内容编辑相关的 shortcut 内置 HTML lint 工具,处于安全考虑和格式适配,你输入的 HTML 可能会被自动调整 + +## 风格底线 + +- **邮件标题小于50字**: 邮件主题行 `--subject` 应控制在 50 字内,避免超长标题带来理解困难 +- **多用列表、表格**:不要堆叠过长的文本段落,请擅长使用列表`
    ` / `
      `或分段 `

      ` +- **列表书写规则**:**不要**用 `

      一、...

      二、...

      ` 这种「中文编号 + 段落」的列表样式,"①②③"、"1) 2) 3)的机械写法也请摒弃;请擅长使用列表格式 `
        ` / `
          `。 +- **正文长度自适应**:不正文长度,但要求**首屏要见到关键信息**。 + +## 格式书写规范 + +电子邮件的 HTML 受客户端兼容性、安全沙箱、服务端 sanitizer 三重约束,跟 Web 浏览器 HTML 不是同一规范体系。下面是飞书邮箱已验证的最纯净、最美观写法,请直接复制使用。 + +### 段落 + +```html +

          文字

          +``` + +### 标题 + +```html +

          一级标题(26px,自动加粗)

          +

          二级标题(22px)

          +

          三级标题(20px)

          +

          四级标题(18px)

          +``` + +### 加粗 + +```html +加粗文字 +``` + +### 斜体 + +```html +斜体文字 +``` + +### 下划线 + +```html +下划线文字 +``` + +### 删除线 + +```html +删除文字 +``` + +### 字号 + +```html +放大到 18px +``` + +### 字体 + +```html +等宽字体 +``` + +### 文字颜色 + +```html +红色文字 +``` + +### 换行 + +```html +第一行
          第二行 +``` + +### 分隔 + +```html +
          +``` + +### 列表 + +```html + +
          + + +
          + + +
            +
          1. + 第一级(ol decimal) +
          2. +
          + +
            +
              +
            • + 第二级(ul circle,缩进 24px) +
            • +
                +
              • + 第三级(ul square,再缩进24px) +
              • +
              +
            • + 第二级(同层) +
            • +
            +
          + +
            +
          1. + 第一级(接续编号) +
          2. +
          +``` + +### 表格 + +```html + + + + + + + + + + + + + + + + + + + + + + + + + + +
          ABC
          B1B2
          a1b1-1b2-1c1
          a2b1-2b2-2c2
          +``` + +### 链接 + +```html +链接文字 +``` + +### AT 用户 + +```html +@姓名 +``` + +**必填字段** `id="at-user-N"`、`mailto:` 和姓名文本 + +### 引用 + +```html +
          引用文字
          +``` + +### 文字高亮(荧光笔风格) + +```html +关键里程碑 +待跟进 +已完成 +``` + +### 文字强调 + +```html +红色加粗 +斜体 +下划线 +删除线 +``` + +### 居中 / 左对齐 / 右对齐 + +```html +
          居中
          +
          左对齐(默认)
          +
          右对齐
          +``` + +### 盒模型 + +```html +
          外边距 8px / 内边距 12px / 宽度 300px
          +``` + +### 边框 + +```html +
          圆角描边
          +``` + +### 透明 + +```html +半透明文字 +``` + +### 颜色(推荐调色盘) + +```html + +主文本 + +副文本 + +浅灰文本 + +蓝色文字 + +深蓝标题 + +警示红 + +紧急橙 +``` + +### URL scheme + +```html +外链 +邮件链接 + + +``` + +## 官方 HTML 模板 + +仓库 [`../assets/templates/`](../assets/templates/) 内预制了若干场景模板,按 LarkSuite mail-editor 原生格式写好。**注意:模板是静态 HTML,没有变量替换能力,AI 需要手工把模板里的样例文本替换成本次邮件的真实内容。** + +| 文件 | 说明 | +|---------------------------------|----------| +| `newsletter--weekly-brief.html` | 资讯周报 | +| `weekly--personal-report.html` | 工作周报(个人) | +| `weekly--team-report.html` | 工作周报(团队) | +| `research--market-report.html` | 调研报告 | +| `job-application--resume.html` | 简历邮件 | + +跟飞书 OAPI 个人邮件模板(`mail.user_mailbox.templates`)不同——OAPI 模板是用户邮箱里的"我的模板",跨客户端可见;这里是仓库里的静态 HTML 文件,AI 单次套用即可。 + +### AI 套用流程 + +1. **判断是否能用模板** — 看用户当前要写的邮件类型(周报 / 调研 / 简历 / 资讯 / ...)能否对上 [`../assets/templates/`](../assets/templates/) 里的某个文件;不匹配就跳过模板,直接按写法规范从零写。 +2. **Read 整个 HTML** — 用 Read 工具完整读取选定的模板文件,理解骨架(章节标题 / 列表层级 / 占位文本 / mention chip / 段落顺序)。 +3. **替换文本内容** — 把模板里的样例文字换成用户当前邮件的真实内容;保留所有 inline style / class / data-* 等结构性属性不动;列表条目 / 表格行可按需增删;不需要的整段(如「风险」「下周计划」)整段删除即可,不要留空骨架。 +4. **调写信 shortcut 生成草稿** — 把替换后的 HTML 通过 `--body` 参数交给写信链路(推荐 `+draft-create` 先存草稿、用户复核后再 `+send`): + + ```bash + lark-cli mail +draft-create --as user \ + --to alice@example.com --subject 'Q3 团队周报' \ + --body "$(cat skills/lark-mail/assets/templates/weekly--team-report.html)" + ``` + + 实际使用时 `$(cat ...)` 可换成 AI 替换文本后写入的本地副本,或直接把替换后的 HTML 字符串作为 `--body` 的值。 + +5. **拿到草稿链接给用户复核** — 写信 shortcut 返回 `reference` 字段(草稿打开链接),把它给用户在飞书邮箱 UI 里打开核对,再决定下一步发送 / 编辑。 + +## 写信 shortcut 的 lint 返回值 + +写信链路(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)调用 `emlbuilder` 之前会强制 lint 净化 HTML,stdout envelope 永远携带两个 count 字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `lint_applied_count` | int | warning 类 finding 的条数(lint 自动修复了多少处装饰性问题,如 `

          ` → 双层 div、`

            /
          • ` → native list-block 等) | +| `original_blocked_count` | int | error 类 finding 的条数(lint 强制删除了多少 XSS / 危险标签 / 危险 URL;非空意味 body 含真正的安全 / 合规问题) | + +默认 envelope **不包含**完整 finding 数组——`lint_applied_count` 已经足够告诉 AI / 用户「lint 改了 N 条」,避免一封含 20 个 mention chip 的周报模板把 envelope 撑到几千 token。 + +要看完整 finding 时加 `--show-lint-details`: + +```bash +lark-cli mail +draft-create --show-lint-details \ + --to alice@example.com --subject 'Hi' --body '

            正文

            ' +``` + +加了 `--show-lint-details` 后 envelope 同时返回 `lint_applied[]` / `original_blocked[]`(每条含 `rule_id` / `severity` / `tag_or_attr` / `excerpt` / `hint`)。**默认场景不要加这个 flag**,徒增 token 消耗。 + +如果只是想预览 lint 会怎么改 HTML,建议直接用 [`+lint-html`](./lark-mail-lint-html.md) 命令——它本来就返回完整 `warnings[]` / `errors[]` + `cleaned_html`,比写信链路 `--show-lint-details` 更清晰。 + +## 相关文档 + +- [`+lint-html` 用法](./lark-mail-lint-html.md) +- 写信 shortcut: [`+send`](./lark-mail-send.md) / [`+draft-create`](./lark-mail-draft-create.md) / [`+reply`](./lark-mail-reply.md) / [`+reply-all`](./lark-mail-reply-all.md) / [`+forward`](./lark-mail-forward.md) / [`+draft-edit`](./lark-mail-draft-edit.md) diff --git a/skills/lark-mail/references/lark-mail-lint-html.md b/skills/lark-mail/references/lark-mail-lint-html.md new file mode 100644 index 000000000..f505ba59b --- /dev/null +++ b/skills/lark-mail/references/lark-mail-lint-html.md @@ -0,0 +1,248 @@ +# mail +lint-html + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解通用安全规则。 + +## 作用 + +`+lint-html` 是邮件 HTML 正文的本地预检工具(read-only,无网络 IO)。 + +- 校验 HTML 是否符合飞书邮箱的兼容性 / 安全 / 原生写法要求; +- 自动修复(可选)非法或不规范写法,输出 `cleaned_html`; +- 不写入任何邮箱状态,不调用任何 OAPI。 + +写信链路(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已**强制内置**同一份 lint,提交前会自动净化并通过 stdout 返回 `lint_applied_count` / `original_blocked_count`(加 `--show-lint-details` 可拿到完整 `lint_applied[]` / `original_blocked[]`,详见 [邮件 HTML 写法指南](./lark-mail-html.md#写信-shortcut-的-lint-返回值))。本命令是写信链路 lint 的预览版,行为一致,调用更轻量,适合: + +- AI / 用户在创建草稿前自检 HTML 会被怎么改写; +- CI 流水线把 HTML 模板当作产物校验。 + +## 命令 + +```bash +# 直接传 HTML +lark-cli mail +lint-html --body '

            正文

            ' + +# 从文件读 HTML(路径必须在 cwd 子树内) +lark-cli mail +lint-html --body-file ./template.html + +# 仅校验不修复(不返回 cleaned_html) +lark-cli mail +lint-html --body '

            x

            ' --auto-fix=false + +# CI 严格模式:任何 warning 都退出非零 +lark-cli mail +lint-html --body-file ./template.html --strict +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--body ` | 二选一 | 待检查的 HTML 内容 | +| `--body-file ` | 二选一 | 从文件读取 HTML,仅支持 cwd 子树(绝对路径 / `..` 越出 cwd 会被拒) | +| `--auto-fix` | 否 | 默认 `true`。`true` 时返回 `cleaned_html`;`false` 时不返回 `cleaned_html` | +| `--strict` | 否 | 默认 `false`。`true` 时把 warning 视作 error 并退出非零(CI 用) | +| `--show-lint-details` | 否 | 默认 `false`。`true` 时 envelope 同时返回 `warnings[]` / `errors[]` 完整 Finding 数组;默认仅返回 `cleaned_html`,避免复杂模板触发数十条装饰性 warning 把响应撑大几千 token | +| `--format ` | 否 | `json`(默认)/ `pretty` / `table` / `csv` / `ndjson` | +| `--jq ` | 否 | 对返回 JSON 用 jq 表达式过滤 | +| `--dry-run` | 否 | 不执行 lint,仅返回 dry-run 描述 | + +## 返回值 + +**默认 envelope**(仅 `cleaned_html`,token-frugal): + +```json +{ + "ok": true, + "data": { + "cleaned_html": "

            ...

            " + } +} +``` + +**加 `--show-lint-details` 后**: + +```json +{ + "ok": true, + "data": { + "cleaned_html": "

            ...

            ", + "warnings": [ + { "rule_id": "...", "severity": "warning", "tag_or_attr": "...", "excerpt": "...", "hint": "..." } + ], + "errors": [ + { "rule_id": "...", "severity": "error", "tag_or_attr": "...", "excerpt": "...", "hint": "..." } + ] + } +} +``` + +| 字段 | 说明 | +|------|------| +| `cleaned_html` | `--auto-fix=true` 时返回的修复后 HTML;warning 已自动修复,error 已删除 | +| `warnings[]` | 警告级 finding 数组(**仅 `--show-lint-details` 时返回**)。无违规时输出 `[]` | +| `errors[]` | 错误级 finding 数组(**仅 `--show-lint-details` 时返回**)。`--strict` 下任一非空都会退出非零 | + +每条 finding 含: + +| 字段 | 说明 | +|------|------| +| `rule_id` | 规则编号(UPPER_SNAKE_CASE) | +| `severity` | `"warning"` 或 `"error"` | +| `tag_or_attr` | 触发规则的 tag / attribute / `style.` | +| `excerpt` | HTML 片段(最多 200 字节,超出截断) | +| `hint` | 可读的修复说明 | + +## 调用示例 + +下面是用 `lark-cli mail +lint-html --body '' --show-lint-details` 实跑得到的典型 case(加 `--show-lint-details` 才能看到 finding;默认只返回 `cleaned_html`),覆盖 error 类(强制删)和 warning 类(自动修复)。 + +### Error 类(强制删除,写信链路也会拒) + +#### 1. `正文 +``` + +输出: + +```html +正文 +``` + +原因:`

            after

            `, Options{AutoFix: autoFix}) - if len(rep.Blocked) != 1 { - t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) - } - if rep.Blocked[0].RuleID != RuleTagScriptBlocked { - t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked) - } - if strings.Contains(rep.CleanedHTML, " content should be deleted, cleaned=%q", rep.CleanedHTML) - } - if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") { - t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML) - } - }) + rep := Run(`

            safe

            after

            `, Options{}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleTagScriptBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked) + } + if strings.Contains(rep.CleanedHTML, " content should be deleted, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML) } } @@ -230,7 +202,7 @@ func TestRun_BlockedTagsRemoved(t *testing.T) { } for input, wantRule := range cases { t.Run(input[:min(len(input), 30)], func(t *testing.T) { - rep := Run(input, Options{AutoFix: true}) + rep := Run(input, Options{}) found := false for _, f := range rep.Blocked { if f.RuleID == wantRule { @@ -248,7 +220,7 @@ func TestRun_BlockedTagsRemoved(t *testing.T) { // TestRun_EventHandlerAttrBlocked verifies on*-handlers are stripped (spec // §4.4 — "属性 on*(onclick 等)"). func TestRun_EventHandlerAttrBlocked(t *testing.T) { - rep := Run(`

            x

            `, Options{AutoFix: true}) + rep := Run(`

            x

            `, Options{}) if len(rep.Blocked) != 1 { t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) } @@ -265,7 +237,7 @@ func TestRun_EventHandlerAttrBlocked(t *testing.T) { // TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors. func TestRun_OnErrorAttrBlocked(t *testing.T) { - rep := Run(``, Options{AutoFix: true}) + rep := Run(``, Options{}) hasErr := false for _, f := range rep.Blocked { if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" { @@ -283,7 +255,7 @@ func TestRun_OnErrorAttrBlocked(t *testing.T) { // TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped. func TestRun_JavaScriptURLBlocked(t *testing.T) { - rep := Run(`click`, Options{AutoFix: true}) + rep := Run(`click`, Options{}) hasErr := false for _, f := range rep.Blocked { if f.RuleID == RuleAttrJSURLBlocked { @@ -300,7 +272,7 @@ func TestRun_JavaScriptURLBlocked(t *testing.T) { // TestRun_VBScriptURLBlocked verifies vbscript: is rejected. func TestRun_VBScriptURLBlocked(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true}) + rep := Run(`x`, Options{}) if len(rep.Blocked) == 0 { t.Errorf("expected vbscript: to be blocked, got 0 findings") } @@ -309,7 +281,7 @@ func TestRun_VBScriptURLBlocked(t *testing.T) { // TestRun_DataNonImageURLBlocked verifies data:text/html is rejected // (only data:image/* is allowed per spec §4.4). func TestRun_DataNonImageURLBlocked(t *testing.T) { - rep := Run(``, Options{AutoFix: true}) + rep := Run(``, Options{}) if len(rep.Blocked) == 0 { t.Errorf("expected data:text/html to be blocked") } @@ -317,7 +289,7 @@ func TestRun_DataNonImageURLBlocked(t *testing.T) { // TestRun_DataImageAllowed verifies data:image/png passes. func TestRun_DataImageAllowed(t *testing.T) { - rep := Run(``, Options{AutoFix: true}) + rep := Run(``, Options{}) for _, f := range rep.Blocked { if f.RuleID == RuleAttrJSURLBlocked { t.Errorf("data:image/* should pass, got %+v", f) @@ -327,7 +299,7 @@ func TestRun_DataImageAllowed(t *testing.T) { // TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass. func TestRun_RelativeURLAllowed(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true}) + rep := Run(`x`, Options{}) for _, f := range rep.Blocked { if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked { t.Errorf("relative URL should pass, got %+v", f) @@ -341,7 +313,7 @@ func TestRun_RelativeURLAllowed(t *testing.T) { // TestRun_StylePropertyDropped verifies non-allow-list properties drop. func TestRun_StylePropertyDropped(t *testing.T) { - rep := Run(`

            x

            `, Options{AutoFix: true}) + rep := Run(`

            x

            `, Options{}) dropped := []string{} for _, f := range rep.Applied { if f.RuleID == RuleStylePropertyDropped { @@ -364,7 +336,7 @@ func TestRun_StylePropertyDropped(t *testing.T) { // TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule. func TestRun_StyleBorderPrefixAllowed(t *testing.T) { - rep := Run(`

            x

            `, Options{AutoFix: true}) + rep := Run(`

            x

            `, Options{}) for _, f := range rep.Applied { if f.RuleID == RuleStylePropertyDropped { t.Errorf("border-* should pass, got %+v", f) @@ -381,7 +353,7 @@ func TestRun_StyleBorderPrefixAllowed(t *testing.T) { // and missed the shorthand form, causing 24px indents to be reset to 0. func TestRun_FeishuListShorthandMarginPreserved(t *testing.T) { in := `
            • indented
            ` - rep := Run(in, Options{AutoFix: true}) + rep := Run(in, Options{}) cleaned := rep.CleanedHTML // Extract just the
              opening tag's style attr (li has its own // independent margin-left:0 longhand which is correct — list indent @@ -412,7 +384,7 @@ func TestRun_EmptyArraysAlwaysPresent(t *testing.T) { // Use
              instead of

              to avoid the Feishu-native paragraph // rewrite autofix, which would surface a finding even on otherwise // clean input. - rep := Run(`

              nothing here
              `, Options{AutoFix: true}) + rep := Run(`
              nothing here
              `, Options{}) if rep.Applied == nil || rep.Blocked == nil { t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked) } @@ -440,7 +412,7 @@ func TestEmptyReport_HasContractFields(t *testing.T) { // the parser doesn't accidentally lose user content. func TestRun_CleanedHTMLPreservesStructure(t *testing.T) { html := `

              title

              body bold end

              • a
              • b
              ` - rep := Run(html, Options{AutoFix: true}) + rep := Run(html, Options{}) if len(rep.Blocked) != 0 { t.Fatalf("unexpected blocked: %+v", rep.Blocked) } @@ -455,7 +427,7 @@ func TestRun_CleanedHTMLPreservesStructure(t *testing.T) { // TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input. func TestRun_EmptyInput(t *testing.T) { - rep := Run("", Options{AutoFix: true}) + rep := Run("", Options{}) if rep.CleanedHTML != "" { t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML) } @@ -466,11 +438,11 @@ func TestRun_EmptyInput(t *testing.T) { // TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings. func TestRun_HasErrorFindingsFlag(t *testing.T) { - rep := Run(``, Options{AutoFix: true}) + rep := Run(``, Options{}) if !rep.HasErrorFindings { t.Error("expected HasErrorFindings=true") } - clean := Run(`

              safe

              `, Options{AutoFix: true}) + clean := Run(`

              safe

              `, Options{}) if clean.HasErrorFindings { t.Error("expected HasErrorFindings=false on clean HTML") } @@ -478,7 +450,7 @@ func TestRun_HasErrorFindingsFlag(t *testing.T) { // TestRun_HasWarningFindingsFlag verifies the flag tracks warnings. func TestRun_HasWarningFindingsFlag(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true}) + rep := Run(`x`, Options{}) if !rep.HasWarningFindings { t.Error("expected HasWarningFindings=true") } @@ -504,7 +476,7 @@ func TestTruncateExcerpt_RespectsCap(t *testing.T) { // produces a short excerpt (envelope size protection). func TestRun_ExcerptCappedForLargeOffender(t *testing.T) { bigAttr := strings.Repeat("a", MaxExcerptBytes*2) - rep := Run(`x`, Options{AutoFix: true}) + rep := Run(`x`, Options{}) if len(rep.Blocked) == 0 { t.Fatal("expected blocked finding") } @@ -519,13 +491,6 @@ func TestRun_ExcerptCappedForLargeOffender(t *testing.T) { // Helpers. // ===================================================================== -func boolStr(b bool) string { - if b { - return "true" - } - return "false" -} - func sliceContains(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { @@ -575,7 +540,7 @@ func TestMapFontSize_ExhaustiveSpan(t *testing.T) { // TestRun_FontTagWithFaceMappedToFontFamily ensures → // font-family inline style. func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true}) + rep := Run(`x`, Options{}) if !strings.Contains(rep.CleanedHTML, "font-family:Arial") { t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML) } @@ -584,7 +549,7 @@ func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) { // TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an // existing style attribute on the same element. func TestRun_FontTagWithExistingStyleMerged(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true}) + rep := Run(`x`, Options{}) if !strings.Contains(rep.CleanedHTML, "line-height:1.6") { t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML) } @@ -595,7 +560,7 @@ func TestRun_FontTagWithExistingStyleMerged(t *testing.T) { // TestRun_CenterTagWithExistingStyleMerged ensures
              's style merge. func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) { - rep := Run(`
              x
              `, Options{AutoFix: true}) + rep := Run(`
              x
              `, Options{}) if !strings.Contains(rep.CleanedHTML, "text-align:center") { t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML) } @@ -606,7 +571,7 @@ func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) { // TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id. func TestRun_MarqueeRetainsClassAndID(t *testing.T) { - rep := Run(`y`, Options{AutoFix: true}) + rep := Run(`y`, Options{}) if !strings.Contains(rep.CleanedHTML, `class="cls"`) { t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML) } @@ -616,47 +581,27 @@ func TestRun_MarqueeRetainsClassAndID(t *testing.T) { } // TestRun_UnknownSchemeWarning verifies an unknown URL scheme produces a -// warning (not an error) and is dropped only when AutoFix is true. +// warning (not an error) and the attribute is dropped. func TestRun_UnknownSchemeWarning(t *testing.T) { - t.Run("autofix-true drops attr", func(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true}) - gotWarn := false - for _, f := range rep.Applied { - if f.RuleID == RuleAttrUnsafeSchemeBlocked { - gotWarn = true - } - } - if !gotWarn { - t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED warning, got %+v", rep.Applied) - } - if strings.Contains(rep.CleanedHTML, "webcal:") { - t.Errorf("expected unknown scheme stripped under AutoFix, cleaned=%q", rep.CleanedHTML) - } - }) - t.Run("autofix-false keeps attr", func(t *testing.T) { - rep := Run(`x`, Options{AutoFix: false}) - if !strings.Contains(rep.CleanedHTML, "webcal:") { - t.Errorf("expected unknown scheme kept under AutoFix=false, cleaned=%q", rep.CleanedHTML) - } - }) - t.Run("strict promotes warning", func(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true, Strict: true}) - gotErr := false - for _, f := range rep.Blocked { - if f.RuleID == RuleAttrUnsafeSchemeBlocked { - gotErr = true - } - } - if !gotErr { - t.Errorf("expected unsafe-scheme to be promoted to error in strict, got %+v", rep.Blocked) + rep := Run(`x`, Options{}) + gotWarn := false + for _, f := range rep.Applied { + if f.RuleID == RuleAttrUnsafeSchemeBlocked { + gotWarn = true } - }) + } + if !gotWarn { + t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED warning, got %+v", rep.Applied) + } + if strings.Contains(rep.CleanedHTML, "webcal:") { + t.Errorf("expected unknown scheme stripped, cleaned=%q", rep.CleanedHTML) + } } // TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..." // is still caught after control-byte stripping in classifyURLValue. func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) { - rep := Run("x", Options{AutoFix: true}) + rep := Run("x", Options{}) gotErr := false for _, f := range rep.Blocked { if f.RuleID == RuleAttrJSURLBlocked { @@ -670,7 +615,7 @@ func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) { // TestRun_FileSchemeBlocked verifies file: URLs are rejected. func TestRun_FileSchemeBlocked(t *testing.T) { - rep := Run(`x`, Options{AutoFix: true}) + rep := Run(`x`, Options{}) if len(rep.Blocked) == 0 { t.Error("expected file: to be blocked") } @@ -679,7 +624,7 @@ func TestRun_FileSchemeBlocked(t *testing.T) { // TestRun_StyleMalformedDeclarationDropped verifies a property without a // colon delimiter is treated as malformed and dropped. func TestRun_StyleMalformedDeclarationDropped(t *testing.T) { - rep := Run(`

              x

              `, Options{AutoFix: true}) + rep := Run(`

              x

              `, Options{}) gotMalformed := false for _, f := range rep.Applied { if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" { @@ -699,34 +644,16 @@ func TestRun_StyleMalformedDeclarationDropped(t *testing.T) { func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) { // Use
              to avoid the Feishu-native paragraph autofix, which adds // a fresh style attribute on the rewritten outer wrapper. - rep := Run(`
              x
              `, Options{AutoFix: true}) + rep := Run(`
              x
              `, Options{}) if strings.Contains(rep.CleanedHTML, "style=") { t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML) } } -// TestRun_StyleAttrAutoFixFalseKeepsOriginal verifies AutoFix=false keeps -// the original style attribute even when properties would have been dropped. -func TestRun_StyleAttrAutoFixFalseKeepsOriginal(t *testing.T) { - rep := Run(`

              x

              `, Options{AutoFix: false}) - gotDropped := false - for _, f := range rep.Applied { - if f.RuleID == RuleStylePropertyDropped { - gotDropped = true - } - } - if !gotDropped { - t.Errorf("expected drop finding for AutoFix=false, got %+v", rep.Applied) - } - if !strings.Contains(rep.CleanedHTML, "position:") { - t.Errorf("expected original style preserved under AutoFix=false, cleaned=%q", rep.CleanedHTML) - } -} - // TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes. func TestRun_StyleEmptyValuePassThrough(t *testing.T) { // Use
              to avoid the Feishu-native paragraph autofix. - rep := Run(`
              x
              `, Options{AutoFix: true}) + rep := Run(`
              x
              `, Options{}) if len(rep.Applied) != 0 { t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied) } @@ -742,7 +669,7 @@ func TestRun_HintsForAllBlockedTags(t *testing.T) { ``, ``, } for _, html := range cases { - rep := Run(html, Options{AutoFix: true}) + rep := Run(html, Options{}) for _, f := range rep.Blocked { if f.Hint == "" { t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html) @@ -758,7 +685,7 @@ func TestRun_HintsForAllWarnTags(t *testing.T) { `x`, `x`, } for _, html := range cases { - rep := Run(html, Options{AutoFix: true}) + rep := Run(html, Options{}) for _, f := range rep.Applied { if f.Hint == "" { t.Errorf("warn rule %s missing hint for %q", f.RuleID, html) @@ -859,7 +786,7 @@ func TestIsEventHandlerAttr_Coverage(t *testing.T) { // TestRun_ParseFailureFallsBackGracefully verifies extreme malformed input // short-circuits to EmptyReport. func TestRun_PlainTextInputProducesNoFindings(t *testing.T) { - rep := Run("just a plain string with no markup", Options{AutoFix: true}) + rep := Run("just a plain string with no markup", Options{}) if len(rep.Blocked) != 0 || len(rep.Applied) != 0 { t.Errorf("plain text should produce no findings, got %+v %+v", rep.Blocked, rep.Applied) } @@ -869,7 +796,7 @@ func TestRun_PlainTextInputProducesNoFindings(t *testing.T) { func TestRun_MultipleErrorsAccumulate(t *testing.T) { html := `x` + `

              y

              ` - rep := Run(html, Options{AutoFix: true}) + rep := Run(html, Options{}) if len(rep.Blocked) < 4 { t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked) } @@ -878,7 +805,7 @@ func TestRun_MultipleErrorsAccumulate(t *testing.T) { // TestRun_NestedStructurePreserved verifies deep nesting passes through. func TestRun_NestedStructurePreserved(t *testing.T) { html := `

              deep

              ` - rep := Run(html, Options{AutoFix: true}) + rep := Run(html, Options{}) if len(rep.Blocked) != 0 { t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked) } @@ -891,7 +818,7 @@ func TestRun_NestedStructurePreserved(t *testing.T) { // blocked tag inside an allowed parent leaves the parent intact. func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) { html := `
              beforeafter
              ` - rep := Run(html, Options{AutoFix: true}) + rep := Run(html, Options{}) if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") { t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML) } @@ -899,3 +826,39 @@ func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) { t.Errorf("script should be removed, cleaned=%q", rep.CleanedHTML) } } + +// TestRun_ListDirectChildNonLIWrapped verifies that a
                  nested +// directly without an
                • wrapper triggers LIST_DIRECT_CHILD_NON_LI and +// the inner
                    ends up wrapped in a synthetic
                  • . Same for
                        . +func TestRun_ListDirectChildNonLIWrapped(t *testing.T) { + cases := []struct { + name string + html string + }{ + {"ul wraps ul", `
                          • x
                        `}, + {"ol wraps ol", `
                          1. x
                        `}, + {"ul wraps div", `
                          orphan
                        • real
                        `}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep := Run(tc.html, Options{}) + gotRule := false + for _, f := range rep.Applied { + if f.RuleID == RuleListDirectChildNonLI { + gotRule = true + break + } + } + if !gotRule { + t.Errorf("expected LIST_DIRECT_CHILD_NON_LI, got %+v", rep.Applied) + } + // The cleaned HTML should not have a direct ul>ul or ol>ol or + // ul>div sequence anymore. + if strings.Contains(rep.CleanedHTML, "
                          wrapper, cleaned=%q", rep.CleanedHTML) + } + }) + } +} diff --git a/shortcuts/mail/lint/rules.go b/shortcuts/mail/lint/rules.go index 178068073..0b1513066 100644 --- a/shortcuts/mail/lint/rules.go +++ b/shortcuts/mail/lint/rules.go @@ -46,6 +46,13 @@ const ( RuleStyleBlockquoteNative = "STYLE_BLOCKQUOTE_NATIVE_INLINE_APPLIED" RuleStyleLinkNative = "STYLE_LINK_NATIVE_INLINE_APPLIED" RuleStyleParaWrapper = "STYLE_PARA_WRAPPER_REWRITTEN" + + // RuleListDirectChildNonLI fires when a
                            or
                              has a non-
                            1. + // element child (e.g. nested
                                  ). HTML spec requires list children + // to be
                                • ; browsers silently hoist the nested list out and the visual + // nesting falls apart. The lib autofixes by wrapping the offending child + // in a synthetic
                                • . + RuleListDirectChildNonLI = "LIST_DIRECT_CHILD_NON_LI" ) // Tag classification ---------------------------------------------------------- @@ -157,8 +164,7 @@ func classifyTag(tag string) (kind, ruleID string) { return "warn", id } // Unknown / niche tags: pass through silently. The cli's existing - // `htmlTagRe` (mail_quote.go:333) tolerates them too, and the server-side - // RemoteSanitizer will remove anything risky regardless. Users authoring + // `htmlTagRe` (mail_quote.go:333) tolerates them too. Users authoring // HTML in Feishu native classes (`adit-html-block*`, `history-quote-*`, // `lark-mail-doc-quote`) hit this path — they MUST pass through unchanged // so reply / forward quote markup survives lint round-trips. (Spec §4.4 diff --git a/shortcuts/mail/lint/types.go b/shortcuts/mail/lint/types.go index b6e9a9596..bc3fd6f83 100644 --- a/shortcuts/mail/lint/types.go +++ b/shortcuts/mail/lint/types.go @@ -7,9 +7,8 @@ // ops. The lib classifies HTML tags / attributes / inline styles into three // tiers (pass / warn-and-autofix / error-delete) per technical-design §4.4 and // the mail-editor `editor-kit` branch DOMPurify config. `