chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package affordance is the lazily-loaded store of usage guidance for
// service-API methods. The source of truth is one markdown file per service in
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
// domain owners maintain it next to skills/ and shortcuts/. A service is read
// and parsed at most once, on first access, so normal command execution never
// touches it.
package affordance
import (
"encoding/json"
"io/fs"
"strings"
"sync"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/registry"
)
var (
mu sync.Mutex
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
)
// SetSource installs the markdown guidance tree (the top-level affordance/
// directory) as the source. Called once at startup before any lookup; clears
// the parse cache so re-sourcing (e.g. in tests) takes effect.
func SetSource(fsys fs.FS) {
mu.Lock()
defer mu.Unlock()
mdSource = fsys
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
}
// For returns the raw affordance overlay for one method, loading the owning
// service on first access. ok is false when there is no entry (absent source,
// parse failure, or unknown method all collapse to "no guidance").
func For(service, methodID string) (json.RawMessage, bool) {
mu.Lock()
defer mu.Unlock()
if !tried[service] {
tried[service] = true
byService[service] = loadService(service)
}
raw, ok := byService[service][methodID]
return raw, ok && len(raw) > 0
}
// loadService parses a service's markdown guidance into per-method overlays,
// marshalling each to JSON so downstream callers keep the same wire shape.
func loadService(service string) map[string]json.RawMessage {
if mdSource == nil {
return nil
}
src, err := fs.ReadFile(mdSource, service+".md")
if err != nil {
return nil
}
m := map[string]json.RawMessage{}
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
if b, err := json.Marshal(a); err == nil {
m[id] = b
}
}
return m
}
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list") via the registry's
// authoritative resource↔id table. Resource names are irregularly pluralised
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
if svc, ok := registry.SchemaCatalog().Service(service); ok {
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
}
return func(h string) string {
if id, ok := byForm[strings.TrimSpace(h)]; ok {
return id
}
return headingToKey(h) // one home for the shortcut/method key convention
}
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"encoding/json"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/meta"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
// paragraph (use_when) and a fenced example.
const fixtureMD = "# approval\n" +
"> skill: lark-approval\n\n" +
"## instances cc\n" +
"把一个审批实例抄送给指定用户。\n\n" +
"### Examples\n\n" +
"**抄送给用户**\n" +
"```bash\n" +
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
"```\n\n" +
"## instances get\n" +
"查询某审批实例详情。\n\n" +
"### Examples\n\n" +
"**按 code 查询**\n" +
"```bash\n" +
"lark-cli approval instances get --instance-code \"x\"\n" +
"```\n"
func TestFor(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
// A seeded method in a seeded service resolves to its overlay.
raw, ok := For("approval", "instances.cc")
if !ok {
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
}
var a struct {
UseWhen []string `json:"use_when"`
Examples []struct {
Command string `json:"command"`
} `json:"examples"`
}
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("overlay is not valid affordance JSON: %v", err)
}
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
t.Errorf("overlay missing use_when/examples: %s", raw)
}
// Misses: unknown method in a known service, and an unknown service, both
// resolve to ok=false (no panic, no error) so callers treat them as "no
// guidance".
if _, ok := For("approval", "instances.no_such_method"); ok {
t.Error("unknown method should be ok=false")
}
if _, ok := For("no_such_service", "x.y"); ok {
t.Error("unknown service should be ok=false")
}
// A second lookup of the same service is served from cache (parsed at most
// once) and stays consistent.
if _, ok := For("approval", "instances.get"); !ok {
t.Error("second lookup in a cached service should still resolve")
}
}
// Non-bullet paragraph lines under any section are preserved as items, not
// dropped (regression: they previously only updated pending, lost without a fence).
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
a, ok := got["foo.bar"]
if !ok {
t.Fatal("method not parsed")
}
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
t.Errorf("Tips paragraph dropped: %v", a.Tips)
}
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}
// The ### Skills section merges with the domain `> skill:` default: domain
// first, then per-command entries, de-duplicated. A command with no ### Skills
// still inherits the domain default.
func TestParseDomainMD_SkillsMerge(t *testing.T) {
md := "# d\n> skill: lark-d\n\n" +
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
}
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
}
}
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
// matches the shortcut command as mounted.
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}
func keysOf(m map[string]meta.Affordance) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+224
View File
@@ -0,0 +1,224 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
//
// # domain optional `> skill: <name>` applied to every method
// ## command e.g. `instances get`
// <lead paragraph> -> use_when (when this command is right)
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### Skills -> skills: bullet skill names, added to the domain default
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
// Parsing is lazy and cached (see For), so the constrained grammar is read at
// most once per domain.
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
// standardSection maps a section heading to its typed Affordance field; any
// other heading becomes an extension.
var standardSection = map[string]string{
"Avoid when": "avoid_when",
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
"Skills": "skills",
}
// mergeSkills returns the domain-default skill followed by a command's own skill
// entries, de-duplicated in author order and empties dropped. Backticks (left by
// the shared bullet parse) are stripped so each entry is a bare skill name.
func mergeSkills(domain string, extra []string) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.Trim(strings.TrimSpace(s), "`")
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(domain)
for _, s := range extra {
add(s)
}
return out
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
// while an entry containing a slash is a name/relative-path reference (e.g.
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
// skills read already accepts — so a per-command entry can point at that
// command's own reference file, not just re-point the domain skill.
func SkillStatPath(entry string) string {
if strings.Contains(entry, "/") {
return entry
}
return entry + "/SKILL.md"
}
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
h = strings.TrimSpace(h)
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
return h
}
return strings.ReplaceAll(h, " ", ".")
}
type mdSection struct {
label string
items []string
cases []meta.AffordanceCase
}
// parseDomainMD parses one domain's markdown into per-method Affordance values,
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
// space→dot rule (valid only where the command form already equals the id).
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
if resolve == nil {
resolve = headingToKey
}
out := map[string]meta.Affordance{}
var skill, curKey string
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
var secs []*mdSection
var sec *mdSection
var pending string
var fence []string
inFence := false
assemble := func() {
if curKey == "" {
return
}
if len(para) > 0 {
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
para = nil
}
var a meta.Affordance
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
var perCmdSkills []string
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
a.AvoidWhen = s.items
case "prerequisites":
a.Prerequisites = s.items
case "tips":
a.Tips = s.items
case "examples":
a.Examples = s.cases
case "skills":
perCmdSkills = s.items
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
a.Skills = s
}
out[curKey] = a
}
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
// flushPending appends a non-bullet paragraph line that was not consumed as
// an example description (i.e. no fence followed) to the current section's
// items, so prose under any section is preserved rather than dropped.
flushPending := func() {
if sec != nil && pending != "" {
sec.items = append(sec.items, linkToBacktick(pending))
pending = ""
}
}
for _, raw := range strings.Split(string(src), "\n") {
line := strings.TrimRight(raw, "\r")
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
flushPending()
assemble()
curKey = resolve(line[3:])
reset()
continue
case strings.HasPrefix(line, "# "):
continue
case strings.HasPrefix(t, "> skill:"):
skill = strings.TrimSpace(t[len("> skill:"):])
continue
case strings.HasPrefix(line, "### "):
flushPending()
sec = &mdSection{label: strings.TrimSpace(line[4:])}
secs = append(secs, sec)
pending, fence, inFence = "", nil, false
continue
}
if curKey == "" {
continue
}
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
if t == "" {
if len(para) > 0 {
useWhen = append(useWhen, strings.Join(para, " "))
para = nil
}
} else {
para = append(para, t)
}
continue
}
// inside a section: a fenced block is an example command; otherwise the
// shape follows the writing (bullet item vs **description** before a fence).
if strings.HasPrefix(t, "```") {
if !inFence {
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
pending = ""
}
continue
}
if inFence {
fence = append(fence, line)
continue
}
if strings.HasPrefix(t, "-") {
flushPending()
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
} else if t != "" {
flushPending()
pending = strings.Trim(t, "* ")
}
}
flushPending()
assemble()
return out
}
+396
View File
@@ -0,0 +1,396 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package apicatalog is the single navigation Module over the API metadata. It
// owns every "which services/resources/methods exist and how does a path
// resolve" question that was previously duplicated across cmd/schema,
// cmd/service, internal/schema and internal/registry. It depends only on
// internal/meta; registry is the source Adapter (EmbeddedCatalog/RuntimeCatalog),
// so apicatalog never imports registry.
package apicatalog
import (
"sort"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// Source records whether a catalog includes the remote overlay. It is carried
// so callers (and tests) can assert determinism instead of guessing.
type Source string
const (
SourceEmbedded Source = "embedded" // compiled-in metadata only; deterministic
SourceRuntime Source = "runtime" // embedded + remote overlay
)
// MethodFilter optionally drops methods (e.g. by identity in strict mode).
// A nil filter includes everything.
type MethodFilter func(meta.Method) bool
// Catalog is a navigation view over services with a name index. It owns its
// ordering — New sorts by name — so WalkMethods/Resolve/Complete are
// deterministic regardless of how the source adapter ordered its input.
type Catalog struct {
source Source
services []meta.Service
byName map[string]meta.Service
}
// New builds a Catalog over the given services, owning its navigation order:
// the slice is copied and sorted by name so callers may pass any order and the
// ordering contract is not delegated to the adapter. The copy is shallow —
// meta.Service values share their Resources maps, which are treated as
// read-only.
func New(source Source, services []meta.Service) Catalog {
sorted := append([]meta.Service(nil), services...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
byName := make(map[string]meta.Service, len(sorted))
for _, s := range sorted {
byName[s.Name] = s
}
return Catalog{source: source, services: sorted, byName: byName}
}
// Source reports embedded vs runtime.
func (c Catalog) Source() Source { return c.source }
// Services returns the services in name order. Treat the result as read-only:
// it is the Catalog's own ordered slice and its element Resources maps are
// shared.
func (c Catalog) Services() []meta.Service { return c.services }
// Service looks up one service by name.
func (c Catalog) Service(name string) (meta.Service, bool) {
s, ok := c.byName[name]
return s, ok
}
// Resolve maps a path (already split into segments) to a Target. An empty path
// is TargetAll. Failures return a *ResolveError carrying the available
// candidates so the command layer can render a hint.
func (c Catalog) Resolve(parts []string) (Target, error) {
if len(parts) == 0 {
return Target{Kind: TargetAll}, nil
}
svc, ok := c.byName[parts[0]]
if !ok {
return Target{}, &ResolveError{Kind: ErrService, Subject: parts[0], Candidates: c.serviceNames()}
}
if len(parts) == 1 {
return Target{Kind: TargetService, Service: svc}, nil
}
res, path, remaining, ok := findResource(svc, parts[1:])
if !ok {
return Target{}, &ResolveError{
Kind: ErrResource,
Subject: svc.Name + "." + strings.Join(parts[1:], "."),
Candidates: resourceNames(svc),
}
}
resPath := strings.Join(path, ".")
if len(remaining) == 0 {
return Target{Kind: TargetResource, Service: svc, Resource: &ResourceRef{Service: svc, Resource: res, Path: path}}, nil
}
methodName := remaining[0]
m, ok := res.Method(methodName)
if !ok {
return Target{}, &ResolveError{
Kind: ErrMethod,
Subject: svc.Name + "." + resPath + "." + methodName,
Candidates: methodNames(res),
}
}
if len(remaining) > 1 {
// Method exists but trailing segments don't resolve — reject so a typo
// doesn't silently return this method's schema.
return Target{}, &ResolveError{
Kind: ErrPath,
Subject: svc.Name + "." + resPath + "." + strings.Join(remaining, "."),
Method: methodName,
Trailing: strings.Join(remaining[1:], "."),
}
}
return Target{Kind: TargetMethod, Service: svc, Method: &MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}}, nil
}
// MethodRefs returns the method refs selected by a resolved Target, filtered:
// TargetAll -> every method, TargetService / TargetResource -> that subtree,
// TargetMethod -> the single method if it passes the filter (else empty). It
// unifies WalkMethods/ServiceMethods/ResourceMethods so the command layer maps a
// Target to refs in one call instead of re-deciding the walker per Kind.
func (c Catalog) MethodRefs(target Target, filter MethodFilter) []MethodRef {
switch target.Kind {
case TargetService:
return ServiceMethods(target.Service, filter)
case TargetResource:
return ResourceMethods(*target.Resource, filter)
case TargetMethod:
if filter != nil && !filter(target.Method.Method) {
return nil
}
return []MethodRef{*target.Method}
case TargetAll:
return c.WalkMethods(filter)
default:
// Unknown / zero-value Kind: return nothing rather than silently
// dumping every method (the safe direction for an invalid Target).
return nil
}
}
// WalkMethods returns one MethodRef per method across all services (optionally
// filtered), recursing nested resources, in a deterministic order: services by
// name, resources by name, methods by name.
func (c Catalog) WalkMethods(filter MethodFilter) []MethodRef {
var out []MethodRef
for _, svc := range c.services {
out = append(out, ServiceMethods(svc, filter)...)
}
return out
}
// ServiceMethods returns the method refs of one service (filtered), recursing
// nested resources, in deterministic resource/method name order.
func ServiceMethods(svc meta.Service, filter MethodFilter) []MethodRef {
var out []MethodRef
walkResources(svc, svc.ResourceList(), nil, filter, &out)
return out
}
// ResourceMethods returns the method refs under one resource (filtered), using
// the resource's resolved path as the base and recursing nested resources.
func ResourceMethods(r ResourceRef, filter MethodFilter) []MethodRef {
var out []MethodRef
for _, m := range r.Resource.MethodList() {
if filter == nil || filter(m) {
out = append(out, MethodRef{Service: r.Service, Resource: r.Resource, ResourcePath: r.Path, Method: m})
}
}
walkResources(r.Service, r.Resource.SubResources(), r.Path, filter, &out)
return out
}
func walkResources(svc meta.Service, resources []meta.Resource, parentPath []string, filter MethodFilter, out *[]MethodRef) {
for _, res := range resources {
path := append(append([]string(nil), parentPath...), res.Name)
for _, m := range res.MethodList() {
if filter == nil || filter(m) {
*out = append(*out, MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m})
}
}
walkResources(svc, res.SubResources(), path, filter, out)
}
}
// Complete returns shell-completion candidates for the schema path argument,
// supporting both the legacy single dotted arg ("im.reac") and the
// space-separated form ("im reactions"). noSpace mirrors cobra's
// ShellCompDirectiveNoSpace (so "service." / "service.resource." stay open for
// the next segment). Filtering uses the caller's MethodFilter so strict-mode
// unavailable methods are hidden.
func (c Catalog) Complete(args []string, toComplete string, filter MethodFilter) (completions []string, noSpace bool) {
// Case 1: legacy single dotted arg — no resolved args yet.
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
for _, name := range c.serviceNames() {
if strings.HasPrefix(name, toComplete) {
completions = append(completions, name+".")
}
}
return completions, true
}
svc, ok := c.byName[parts[0]]
if !ok {
return nil, false
}
completions = c.completeDotted(svc, strings.Join(parts[1:], "."), filter)
allTrailingDot := len(completions) > 0
for _, comp := range completions {
if !strings.HasSuffix(comp, ".") {
allTrailingDot = false
break
}
}
return completions, allTrailingDot
}
// Case 2: space-separated form — args holds resolved segments.
svc, ok := c.byName[args[0]]
if !ok {
return nil, false
}
resource, _, _, ok := findResource(svc, args[1:])
if !ok {
// No resource matched yet — suggest top-level resources reachable in the
// current identity mode.
return completeChildren(svc.ResourceList(), nil, toComplete, filter), false
}
// Positioned in a resource — offer its methods and its sub-resources, so the
// next segment can drill deeper, symmetric to findResource's descent.
return completeChildren(resource.SubResources(), resource.MethodList(), toComplete, filter), false
}
// completeDotted suggests dotted completions for the text after the service
// segment. It descends fully-typed "resource." segments (longest match per
// level, so flat dotted keys like "chat.members" and genuinely nested resources
// both resolve), then offers the reachable sub-resources (as "…name.") and the
// methods (as "…name") of the level it lands in whose names extend the trailing
// partial token. This descent is symmetric to findResource, so completion can
// reach every method Resolve can.
func (c Catalog) completeDotted(svc meta.Service, afterService string, filter MethodFilter) []string {
subs := svc.ResourceList()
base := svc.Name
rest := afterService
var here *meta.Resource // resource we're positioned in; nil at the service root
for {
matched, n, ok := longestResourceFollowedByDot(subs, rest)
if !ok {
break
}
base += "." + matched.Name
rest = rest[n:]
r := matched
here = &r
subs = matched.SubResources()
}
var out []string
for _, sub := range subs {
if strings.HasPrefix(sub.Name, rest) && resourceReachable(sub, filter) {
out = append(out, base+"."+sub.Name+".")
}
}
if here != nil {
for _, m := range here.MethodList() {
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, rest) {
out = append(out, base+"."+m.Name)
}
}
}
sort.Strings(out)
return out
}
// completeChildren returns the sorted next-segment candidates at one level: the
// (filtered) methods and the reachable sub-resources whose names extend prefix.
// Methods are terminal; sub-resources are bare names the caller drills into on
// the next segment.
func completeChildren(subResources []meta.Resource, methods []meta.Method, prefix string, filter MethodFilter) []string {
var out []string
for _, m := range methods {
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, prefix) {
out = append(out, m.Name)
}
}
for _, sub := range subResources {
if strings.HasPrefix(sub.Name, prefix) && resourceReachable(sub, filter) {
out = append(out, sub.Name)
}
}
sort.Strings(out)
return out
}
// longestResourceFollowedByDot finds the longest resource in resources whose
// name is a fully-typed segment of text (text begins with "name."), returning
// it, the byte length consumed (incl. the dot), and whether one matched.
func longestResourceFollowedByDot(resources []meta.Resource, text string) (meta.Resource, int, bool) {
best := meta.Resource{}
bestLen := -1
for _, r := range resources {
if len(r.Name) > bestLen && strings.HasPrefix(text, r.Name+".") {
best = r
bestLen = len(r.Name)
}
}
if bestLen < 0 {
return meta.Resource{}, 0, false
}
return best, len(best.Name) + 1, true
}
// findResource resolves a resource path against a service, descending nested
// resources. At each level it consumes the longest leading run of parts that
// names a resource at that level, so both flat dotted keys ("chat.members")
// and genuinely nested resources ("spaces" > "items") resolve. This descent is
// symmetric to walkResources, which guarantees every path WalkMethods emits
// resolves back (the round-trip contract). Returns the deepest matched resource
// (Name injected), its path segments, the unconsumed remainder, and whether
// anything matched.
//
// Descent is greedy and resource-first: the one ambiguous case is a resource
// that has BOTH a method and a sub-resource of the same name — the sub-resource
// wins and shadows the method, so Resolve can never reach that method. Real
// metadata never collides the two, so this is theoretical.
func findResource(svc meta.Service, parts []string) (res meta.Resource, path []string, remaining []string, ok bool) {
level := svc.Resources
remaining = parts
for len(remaining) > 0 {
matched, name, n := longestResourcePrefix(level, remaining)
if n == 0 {
break
}
matched.Name = name
res = matched
path = append(path, name)
remaining = remaining[n:]
level = matched.Resources
ok = true
}
return res, path, remaining, ok
}
// longestResourcePrefix finds the longest leading run of segs (joined by ".")
// that names a resource in level, returning the resource, its dotted name, and
// the number of segments consumed (0 if none match). Longest-first lets a flat
// dotted key win over its single leading segment when present.
func longestResourcePrefix(level map[string]meta.Resource, segs []string) (meta.Resource, string, int) {
for i := len(segs); i >= 1; i-- {
name := strings.Join(segs[:i], ".")
if r, ok := level[name]; ok {
return r, name, i
}
}
return meta.Resource{}, "", 0
}
// resourceReachable reports whether a resource exposes a method reachable under
// the filter — directly or in any nested sub-resource (a nil filter accepts any
// method). A resource whose methods are all filtered out but which contains a
// reachable nested method is still offerable, so completion can drill into it.
func resourceReachable(res meta.Resource, filter MethodFilter) bool {
for _, m := range res.MethodList() {
if filter == nil || filter(m) {
return true
}
}
for _, sub := range res.SubResources() {
if resourceReachable(sub, filter) {
return true
}
}
return false
}
func (c Catalog) serviceNames() []string {
names := make([]string, len(c.services))
for i, s := range c.services {
names[i] = s.Name
}
return names // c.services is already name-sorted
}
func resourceNames(svc meta.Service) []string { return sortedKeys(svc.Resources) }
func methodNames(res meta.Resource) []string { return sortedKeys(res.Methods) }
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+340
View File
@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog_test
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
)
// testCatalog builds a small embedded catalog: services drive (no resources)
// and im with a dotted resource (chat.members), a multi-method resource
// (reactions, where list is user-only), and images.
func testCatalog() apicatalog.Catalog {
im := meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"resources": map[string]interface{}{
"chat.members": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
"reactions": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
"images": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
},
})
drive := meta.ServiceFromMap(map[string]interface{}{"name": "drive"})
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{drive, im}) // already name-sorted
}
func TestNew_PreservesOrderAndLookup(t *testing.T) {
c := testCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("source = %q", c.Source())
}
names := []string{}
for _, s := range c.Services() {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"drive", "im"}) {
t.Errorf("Services order = %v, want [drive im]", names)
}
if _, ok := c.Service("im"); !ok {
t.Error("Service(im) not found")
}
if _, ok := c.Service("nope"); ok {
t.Error("Service(nope) should not be found")
}
}
// TestNew_SortsAndIsolatesInput pins the ordering contract New owns: it sorts
// arbitrary input by service name and shallow-copies the slice so later caller
// mutation can't reorder the Catalog.
func TestNew_SortsAndIsolatesInput(t *testing.T) {
in := []meta.Service{
meta.ServiceFromMap(map[string]interface{}{"name": "zeta"}),
meta.ServiceFromMap(map[string]interface{}{"name": "alpha"}),
}
c := apicatalog.New(apicatalog.SourceEmbedded, in)
names := func() []string {
var out []string
for _, s := range c.Services() {
out = append(out, s.Name)
}
return out
}
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
t.Errorf("New did not sort unsorted input: %v", got)
}
// Mutating the caller's slice afterward must not reorder the Catalog.
in[0] = meta.ServiceFromMap(map[string]interface{}{"name": "MUTATED"})
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
t.Errorf("Catalog order changed after caller mutated its input slice: %v", got)
}
}
func TestWalkMethods_AllAndFiltered(t *testing.T) {
c := testCatalog()
all := c.WalkMethods(nil)
got := map[string]bool{}
for _, r := range all {
got[r.SchemaPath()] = true
}
want := []string{
"im.chat.members.create",
"im.images.create",
"im.reactions.create",
"im.reactions.list",
}
if len(all) != len(want) {
t.Fatalf("WalkMethods(nil) = %d refs, want %d (%v)", len(all), len(want), got)
}
for _, w := range want {
if !got[w] {
t.Errorf("WalkMethods(nil) missing %q", w)
}
}
// Deterministic order: services by name, resources by name, methods by name.
var order []string
for _, r := range all {
order = append(order, r.SchemaPath())
}
if !reflect.DeepEqual(order, want) {
t.Errorf("WalkMethods order = %v, want %v", order, want)
}
// Filter to bot-only ("tenant"): reactions.list (user-only) drops; methods
// with no accessTokens are permissive and stay.
botOnly := func(m meta.Method) bool {
if m.AccessTokens == nil {
return true
}
for _, tok := range m.AccessTokens {
if tok == "tenant" {
return true
}
}
return false
}
filtered := c.WalkMethods(botOnly)
for _, r := range filtered {
if r.SchemaPath() == "im.reactions.list" {
t.Error("filtered walk should drop user-only im.reactions.list")
}
}
if len(filtered) != len(all)-1 {
t.Errorf("filtered walk = %d, want %d", len(filtered), len(all)-1)
}
}
func TestMethodRef_Paths_DottedResourceStaysOneSegment(t *testing.T) {
c := testCatalog()
target, err := c.Resolve([]string{"im", "chat.members", "create"})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if target.Kind != apicatalog.TargetMethod {
t.Fatalf("kind = %v", target.Kind)
}
m := target.Method
if m.SchemaPath() != "im.chat.members.create" {
t.Errorf("SchemaPath = %q", m.SchemaPath())
}
if !reflect.DeepEqual(m.CommandPath(), []string{"im", "chat.members", "create"}) {
t.Errorf("CommandPath = %v", m.CommandPath())
}
if m.ResourceName() != "chat.members" {
t.Errorf("ResourceName = %q, want chat.members (one segment)", m.ResourceName())
}
if m.Method.Name != "create" {
t.Errorf("Method.Name not injected: %q", m.Method.Name)
}
}
func TestResolve_DottedAndSplitFormsEquivalent(t *testing.T) {
c := testCatalog()
// schema.ParsePath splits both "im.chat.members.create" and
// "im chat.members create" into segments; findResource's longest-prefix
// must resolve the dotted resource either way.
a, errA := c.Resolve([]string{"im", "chat", "members", "create"}) // fully split
b, errB := c.Resolve([]string{"im", "chat.members", "create"}) // resource as one segment
if errA != nil || errB != nil {
t.Fatalf("errA=%v errB=%v", errA, errB)
}
if a.Method.SchemaPath() != b.Method.SchemaPath() || a.Method.SchemaPath() != "im.chat.members.create" {
t.Errorf("forms diverged: %q vs %q", a.Method.SchemaPath(), b.Method.SchemaPath())
}
}
func TestResolve_Targets(t *testing.T) {
c := testCatalog()
if tg, _ := c.Resolve(nil); tg.Kind != apicatalog.TargetAll {
t.Errorf("empty -> %v, want all", tg.Kind)
}
if tg, _ := c.Resolve([]string{"im"}); tg.Kind != apicatalog.TargetService || tg.Service.Name != "im" {
t.Errorf("[im] -> %v/%q", tg.Kind, tg.Service.Name)
}
if tg, _ := c.Resolve([]string{"im", "reactions"}); tg.Kind != apicatalog.TargetResource || tg.Resource.SchemaPath() != "im.reactions" {
t.Errorf("[im reactions] -> %v", tg.Kind)
}
}
func TestResolve_Errors(t *testing.T) {
c := testCatalog()
cases := []struct {
parts []string
kind apicatalog.ResolveErrorKind
}{
{[]string{"nope"}, apicatalog.ErrService},
{[]string{"im", "nope"}, apicatalog.ErrResource},
{[]string{"im", "reactions", "nope"}, apicatalog.ErrMethod},
{[]string{"im", "reactions", "list", "extra"}, apicatalog.ErrPath},
}
for _, tc := range cases {
_, err := c.Resolve(tc.parts)
var re *apicatalog.ResolveError
if !errors.As(err, &re) {
t.Errorf("%v -> err %v, want *ResolveError", tc.parts, err)
continue
}
if re.Kind != tc.kind {
t.Errorf("%v -> kind %q, want %q", tc.parts, re.Kind, tc.kind)
}
if tc.kind != apicatalog.ErrPath && len(re.Candidates) == 0 {
t.Errorf("%v -> expected candidates", tc.parts)
}
}
}
// nestedCatalog adds a genuinely nested resource (spaces > items) on top of a
// flat dotted resource (chat.members), so the round-trip contract is exercised
// for real nesting — not just flat dotted keys.
func nestedCatalog() apicatalog.Catalog {
im := meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"resources": map[string]interface{}{
"chat.members": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
"spaces": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
"resources": map[string]interface{}{
"items": map[string]interface{}{
"methods": map[string]interface{}{"get": map[string]interface{}{}},
},
},
},
},
})
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{im})
}
// TestResolve_WalkMethodsRoundTrip is the core catalog contract: every method
// WalkMethods emits must Resolve back to the same method — both from its dotted
// SchemaPath (fully split) and from its CommandPath (resource as one segment).
// This pins findResource's nested-resource descent symmetric to walkResources,
// so "traversable" implies "resolvable".
func TestResolve_WalkMethodsRoundTrip(t *testing.T) {
for _, c := range []apicatalog.Catalog{testCatalog(), nestedCatalog()} {
for _, ref := range c.WalkMethods(nil) {
want := ref.SchemaPath()
for _, parts := range [][]string{
strings.Split(want, "."), // fully-split dotted form
ref.CommandPath(), // command form (resource stays one segment)
} {
tg, err := c.Resolve(parts)
if err != nil {
t.Errorf("round-trip %v: %v", parts, err)
continue
}
if tg.Kind != apicatalog.TargetMethod {
t.Errorf("round-trip %v: kind=%v, want method", parts, tg.Kind)
continue
}
if tg.Method.SchemaPath() != want {
t.Errorf("round-trip %v: resolved to %q, want %q", parts, tg.Method.SchemaPath(), want)
}
}
}
}
}
// TestComplete_Nested pins completion closure for genuinely nested resources:
// both the dotted and space forms must reach a nested method, symmetric to
// Resolve (findResource descends, so completion must too).
func TestComplete_Nested(t *testing.T) {
c := nestedCatalog()
// dotted: under a resource, offer its methods AND its sub-resources
if comps, ns := c.Complete(nil, "im.spaces.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.create", "im.spaces.items."}) || ns {
t.Errorf("Complete([], im.spaces.) = %v noSpace=%v, want [im.spaces.create im.spaces.items.] false", comps, ns)
}
// dotted: drill into the nested sub-resource's method
if comps, ns := c.Complete(nil, "im.spaces.items.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items.get"}) || ns {
t.Errorf("Complete([], im.spaces.items.) = %v noSpace=%v, want [im.spaces.items.get] false", comps, ns)
}
// dotted: partial sub-resource name -> the sub-resource (NoSpace, more to type)
if comps, ns := c.Complete(nil, "im.spaces.it", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items."}) || !ns {
t.Errorf("Complete([], im.spaces.it) = %v noSpace=%v, want [im.spaces.items.] true", comps, ns)
}
// space form: under a resource, offer methods AND sub-resources
if comps, _ := c.Complete([]string{"im", "spaces"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "items"}) {
t.Errorf("Complete([im spaces], '') = %v, want [create items]", comps)
}
// space form: drill into the nested sub-resource's methods
if comps, _ := c.Complete([]string{"im", "spaces", "items"}, "", nil); !reflect.DeepEqual(comps, []string{"get"}) {
t.Errorf("Complete([im spaces items], '') = %v, want [get]", comps)
}
}
func TestComplete(t *testing.T) {
c := testCatalog()
// dotted: service prefix -> "im." (NoSpace)
if comps, ns := c.Complete(nil, "i", nil); !reflect.DeepEqual(comps, []string{"im."}) || !ns {
t.Errorf("Complete([], i) = %v noSpace=%v", comps, ns)
}
// dotted: resource prefix -> "im.reactions." (NoSpace)
if comps, _ := c.Complete(nil, "im.rea", nil); !reflect.DeepEqual(comps, []string{"im.reactions."}) {
t.Errorf("Complete([], im.rea) = %v", comps)
}
// space form: resource candidates under im (deterministic order)
comps, ns := c.Complete([]string{"im"}, "", nil)
if !reflect.DeepEqual(comps, []string{"chat.members", "images", "reactions"}) || ns {
t.Errorf("Complete([im], '') = %v noSpace=%v", comps, ns)
}
// space form: method candidates under reactions
if comps, _ := c.Complete([]string{"im", "reactions"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "list"}) {
t.Errorf("Complete([im reactions], '') = %v", comps)
}
// filter applied: bot-only hides user-only list
botOnly := func(m meta.Method) bool {
if m.AccessTokens == nil {
return true
}
for _, tok := range m.AccessTokens {
if tok == "tenant" {
return true
}
}
return false
}
if comps, _ := c.Complete([]string{"im", "reactions"}, "", botOnly); !reflect.DeepEqual(comps, []string{"create"}) {
t.Errorf("Complete with bot filter = %v, want [create]", comps)
}
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
import (
"strings"
"github.com/larksuite/cli/internal/meta"
)
// TargetKind classifies what a schema/command path resolves to.
type TargetKind string
const (
TargetAll TargetKind = "all" // empty path: every method
TargetService TargetKind = "service" // <service>
TargetResource TargetKind = "resource" // <service> <resource...>
TargetMethod TargetKind = "method" // <service> <resource...> <method>
)
// Target is the result of Catalog.Resolve. Resource and Method are populated
// only for TargetResource and TargetMethod respectively.
type Target struct {
Kind TargetKind
Service meta.Service
Resource *ResourceRef
Method *MethodRef
}
// ResourceRef identifies one resource within a service. Path holds the resource
// path segments (one element for the common flat dotted resource like
// "chat.members"; multiple for genuinely nested resources).
type ResourceRef struct {
Service meta.Service
Resource meta.Resource
Path []string
}
// MethodRef identifies one method, carrying the full navigation context so the
// command path and schema path can be derived without re-walking the catalog.
type MethodRef struct {
Service meta.Service
Resource meta.Resource
ResourcePath []string
Method meta.Method
}
// SchemaPath is the dotted "service.resource" identifier.
func (r ResourceRef) SchemaPath() string {
return r.Service.Name + "." + strings.Join(r.Path, ".")
}
// ServiceName returns the owning service name.
func (r MethodRef) ServiceName() string { return r.Service.Name }
// ResourceName is the dotted resource path, e.g. "chat.members".
func (r MethodRef) ResourceName() string { return strings.Join(r.ResourcePath, ".") }
// MethodName returns the method's own name.
func (r MethodRef) MethodName() string { return r.Method.Name }
// SchemaPath is the dotted "service.resource.method" identifier, e.g.
// "im.chat.members.create".
func (r MethodRef) SchemaPath() string {
return r.Service.Name + "." + strings.Join(r.ResourcePath, ".") + "." + r.Method.Name
}
// CommandPath is the CLI argv segments, e.g. ["im", "chat.members", "create"].
func (r MethodRef) CommandPath() []string {
out := make([]string, 0, len(r.ResourcePath)+2)
out = append(out, r.Service.Name)
out = append(out, r.ResourcePath...)
return append(out, r.Method.Name)
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
import "strings"
// ParsePath normalizes positional command arguments into the path segments
// Resolve consumes. It accepts two equivalent forms:
//
// im.messages.reply -> single arg, split on "."
// im messages reply -> multiple args, used as-is
//
// "im chat.members bots" as a single quoted arg is NOT supported; quote
// arguments individually if your shell needs it. A resource keeps its internal
// dots when passed as one segment (e.g. "chat.members"); findResource's
// longest-prefix descent resolves both the split and the one-segment forms to
// the same target. Returns nil for zero args (bare invocation -> TargetAll).
func ParsePath(args []string) []string {
switch len(args) {
case 0:
return nil
case 1:
if strings.Contains(args[0], ".") {
return strings.Split(args[0], ".")
}
return []string{args[0]}
default:
return args
}
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog_test
import (
"reflect"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
func TestParsePath(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{"empty args -> nil", nil, nil},
{"empty slice -> nil", []string{}, nil},
{"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}},
{"single no-dot", []string{"im"}, []string{"im"}},
{"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}},
{"two args", []string{"im", "messages"}, []string{"im", "messages"}},
{"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}},
{"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := apicatalog.ParsePath(tt.args)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
// ResolveErrorKind classifies a Resolve failure so the command layer can render
// the right hint without re-deriving what was being looked up.
type ResolveErrorKind string
const (
ErrService ResolveErrorKind = "service"
ErrResource ResolveErrorKind = "resource"
ErrMethod ResolveErrorKind = "method"
ErrPath ResolveErrorKind = "path" // method exists but trailing segments don't resolve
)
// ResolveError is returned by Catalog.Resolve. Subject is the dotted thing that
// failed to resolve; Candidates lists the available names at that level (nil for
// ErrPath, which instead carries the matched Method and the unresolved Trailing).
type ResolveError struct {
Kind ResolveErrorKind
Subject string
Candidates []string
Method string
Trailing string
}
func (e *ResolveError) Error() string {
return "unknown " + string(e.Kind) + ": " + e.Subject
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"fmt"
)
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
// from application/get. On a successful fetch it always returns a non-nil slice
// (empty when callback_info is absent or lists no callbacks) so callers can
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
// that must fail the precheck — from a fetch error (nil), which is a
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
App struct {
CallbackInfo *struct {
SubscribedCallbacks []string `json:"subscribed_callbacks"`
} `json:"callback_info"`
} `json:"app"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode application response: %w", err)
}
// callback_info also carries callback_type (e.g. "websocket"); it is
// intentionally not parsed or validated. Feishu open-platform callbacks are
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
// event source, so subscribed_callbacks alone is sufficient for the precheck.
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
callbacks := []string{}
if ci := envelope.Data.App.CallbackInfo; ci != nil {
callbacks = append(callbacks, ci.SubscribedCallbacks...)
}
return callbacks, nil
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"errors"
"testing"
)
var errFakeFetch = errors.New("fake fetch error")
type fakeCallbackClient struct {
raw string
err error
}
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if f.err != nil {
return nil, f.err
}
return json.RawMessage(f.raw), nil
}
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []string{"card.action.trigger", "profile.view.get"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
// A successful fetch with no callback_info means "zero callbacks subscribed",
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
// the precheck reports a required callback as missing instead of skipping.
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
if err == nil {
t.Fatal("expected error")
}
if got != nil {
t.Errorf("got %v, want nil on fetch error", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
// callback_info present but subscribed_callbacks explicitly null → must be
// a non-nil empty slice so the precheck reports missing callbacks.
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package appmeta exposes read-only views of a Feishu app's published version, subscribed event types, and scopes.
package appmeta
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/event"
)
// APIClient aliases event.APIClient so one concrete adapter satisfies event, appmeta, and consume.
type APIClient = event.APIClient
// AppVersion is the projected subset of one /app_versions item preflight cares about.
type AppVersion struct {
VersionID string
Version string
EventTypes []string
TenantScopes []string
}
const appVersionStatusPublished = 1
// FetchCurrentPublished returns the most recently published version of appID, or (nil, nil) if never published.
// page_size=2 suffices: Feishu disallows a new version while an in-progress one exists, so the first status==1 item with publish_time is the live one.
func FetchCurrentPublished(ctx context.Context, client APIClient, appID string) (*AppVersion, error) {
path := fmt.Sprintf(
"/open-apis/application/v6/applications/%s/app_versions?lang=zh_cn&page_size=2",
appID,
)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
Items []struct {
VersionID string `json:"version_id"`
Version string `json:"version"`
Status int `json:"status"`
PublishTime json.RawMessage `json:"publish_time"`
EventInfos []struct {
EventType string `json:"event_type"`
} `json:"event_infos"`
Scopes []struct {
Scope string `json:"scope"`
TokenTypes []string `json:"token_types"`
} `json:"scopes"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode app_versions response: %w", err)
}
for _, it := range envelope.Data.Items {
if it.Status != appVersionStatusPublished || !publishTimeSet(it.PublishTime) {
continue
}
v := &AppVersion{
VersionID: it.VersionID,
Version: it.Version,
}
for _, e := range it.EventInfos {
if e.EventType != "" {
v.EventTypes = append(v.EventTypes, e.EventType)
}
}
for _, s := range it.Scopes {
if s.Scope != "" && containsString(s.TokenTypes, "tenant") {
v.TenantScopes = append(v.TenantScopes, s.Scope)
}
}
return v, nil
}
return nil, nil
}
// publishTimeSet rejects null and empty-string; any other value is a real publish_time.
func publishTimeSet(raw json.RawMessage) bool {
s := string(raw)
return s != "" && s != "null" && s != `""`
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
+138
View File
@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/event/testutil"
)
const respFourVersions = `{
"code": 0,
"data": {
"has_more": false,
"items": [
{"version_id": "oav_draft", "version": "1.0.3", "status": 4, "publish_time": null,
"event_infos": [{"event_type": "im.message.receive_v1"}, {"event_type": "mail.user_mailbox.event.message_received_v1"}],
"scopes": [{"scope": "draft:only", "token_types": ["tenant"]}]
},
{"version_id": "oav_latest", "version": "1.0.2", "status": 1, "publish_time": "1776684746",
"event_infos": [
{"event_type": "im.message.receive_v1"},
{"event_type": "im.message.message_read_v1"}
],
"scopes": [
{"scope": "im:message", "token_types": ["tenant", "user"]},
{"scope": "im:message.group_at_msg", "token_types": ["tenant"]},
{"scope": "contact:user:readonly", "token_types": ["user"]}
]
}
]
}
}`
func TestFetchCurrentPublished_SelectsLatestPublished(t *testing.T) {
c := &testutil.StubAPIClient{Body: respFourVersions}
v, err := FetchCurrentPublished(context.Background(), c, "cli_test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v == nil {
t.Fatal("expected a version, got nil")
}
if v.VersionID != "oav_latest" {
t.Errorf("VersionID = %q, want oav_latest", v.VersionID)
}
if v.Version != "1.0.2" {
t.Errorf("Version = %q, want 1.0.2", v.Version)
}
wantEvents := map[string]bool{"im.message.receive_v1": true, "im.message.message_read_v1": true}
if len(v.EventTypes) != len(wantEvents) {
t.Fatalf("EventTypes = %v, want %v", v.EventTypes, wantEvents)
}
for _, e := range v.EventTypes {
if !wantEvents[e] {
t.Errorf("unexpected event type %q in %v", e, v.EventTypes)
}
}
wantTenant := map[string]bool{"im:message": true, "im:message.group_at_msg": true}
if len(v.TenantScopes) != len(wantTenant) {
t.Fatalf("TenantScopes = %v, want %v", v.TenantScopes, wantTenant)
}
for _, s := range v.TenantScopes {
if !wantTenant[s] {
t.Errorf("unexpected tenant scope %q in %v", s, v.TenantScopes)
}
}
}
func TestFetchCurrentPublished_PathContainsQuery(t *testing.T) {
c := &testutil.StubAPIClient{Body: respFourVersions}
_, _ = FetchCurrentPublished(context.Background(), c, "cli_x")
for _, want := range []string{
"/open-apis/application/v6/applications/cli_x/app_versions",
"lang=zh_cn",
"page_size=2",
} {
if !strings.Contains(c.GotPath, want) {
t.Errorf("path %q missing %q", c.GotPath, want)
}
}
}
func TestFetchCurrentPublished_NoPublishedYet(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
{"version_id":"oav_draft","status":4,"publish_time":null,"event_infos":[],"scopes":[]}
]}}`}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != nil {
t.Errorf("want nil (app never published), got %+v", v)
}
}
func TestFetchCurrentPublished_EmptyItems(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[]}}`}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != nil {
t.Errorf("want nil for empty items, got %+v", v)
}
}
func TestFetchCurrentPublished_APIErrorPropagated(t *testing.T) {
want := errors.New("insufficient permission level")
c := &testutil.StubAPIClient{Err: want}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if !errors.Is(err, want) {
t.Errorf("err = %v, want wrapping %v", err, want)
}
if v != nil {
t.Errorf("want nil version on error, got %+v", v)
}
}
func TestFetchCurrentPublished_PublishTimeEmptyStringTreatedAsUnpublished(t *testing.T) {
c := &testutil.StubAPIClient{Body: `{"code":0,"data":{"items":[
{"version_id":"oav_x","status":1,"publish_time":"","event_infos":[],"scopes":[]}
]}}`}
v, err := FetchCurrentPublished(context.Background(), c, "cli_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != nil {
t.Errorf("want nil (empty publish_time), got %+v", v)
}
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
)
// Terminal registration outcomes, exposed for typed classification by callers.
var (
ErrRegistrationDenied = errors.New("app registration denied by user")
ErrRegistrationExpired = errors.New("device code expired, please try again")
ErrRegistrationTimedOut = errors.New("app registration timed out, please try again")
)
// Protocol defaults, mirroring the official SDK registration flow.
const (
registrationBootstrapBrand = core.BrandFeishu
defaultPollIntervalSeconds = 5
defaultExpireInSeconds = 600
beginRequestTimeout = 30 * time.Second
maxPollIntervalSeconds = 60
)
// normalizedInterval clamps a non-positive poll interval to the protocol default.
func normalizedInterval(v int) int {
if v <= 0 {
return defaultPollIntervalSeconds
}
return v
}
// normalizedExpireIn clamps a non-positive expiry budget to the protocol default.
func normalizedExpireIn(v int) int {
if v <= 0 {
return defaultExpireInSeconds
}
return v
}
// registrationContextError maps a done context to its terminal reason, keeping the cause.
func registrationContextError(ctx context.Context) error {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("%w: %w", ErrRegistrationTimedOut, ctx.Err())
}
return fmt.Errorf("app registration cancelled: %w", ctx.Err())
}
// AppRegistrationResponse is the response from the app registration begin endpoint.
type AppRegistrationResponse struct {
DeviceCode string
UserCode string
VerificationUri string
VerificationUriComplete string
ExpiresIn int
Interval int
}
// AppRegistrationResult is the result of a successful app registration poll.
type AppRegistrationResult struct {
ClientID string
ClientSecret string
UserInfo *AppRegUserInfo
}
// AppRegUserInfo contains user info returned from app registration.
type AppRegUserInfo struct {
OpenID string
TenantBrand string // "feishu" or "lark"
}
// appRegistrationEndpoint returns the brand's accounts registration endpoint.
func appRegistrationEndpoint(brand core.LarkBrand) string {
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
}
// RequestAppRegistration initiates the device flow. The registration protocol
// always bootstraps on Feishu; brand selects the user-facing verification host.
// The request is bounded by ctx and a begin timeout.
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout)
defer cancel()
ep := core.ResolveEndpoints(brand)
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
form := url.Values{}
form.Set("action", "begin")
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration failed: read body: %w", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("app registration failed: HTTP %d response not JSON", resp.StatusCode)
}
_, hasError := data["error"]
if resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("app registration failed: %s", msg)
}
// The protocol field is expire_in; accept the legacy expires_in spelling,
// then normalize to protocol defaults.
expiresIn := getInt(data, "expire_in", 0)
if expiresIn <= 0 {
expiresIn = getInt(data, "expires_in", 0)
}
expiresIn = normalizedExpireIn(expiresIn)
interval := normalizedInterval(getInt(data, "interval", 0))
deviceCode := getStr(data, "device_code")
if deviceCode == "" {
return nil, fmt.Errorf("app registration failed: response missing device_code")
}
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
return &AppRegistrationResponse{
DeviceCode: deviceCode,
UserCode: getStr(data, "user_code"),
VerificationUri: verificationUri,
VerificationUriComplete: verificationUriComplete,
ExpiresIn: expiresIn,
Interval: interval,
}, nil
}
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
func BuildVerificationURL(baseURL, cliVersion string) string {
sep := "&"
if !strings.Contains(baseURL, "?") {
sep = "?"
}
return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) +
"&ocv=" + url.QueryEscape(cliVersion) +
"&from=cli"
}
// pollOnce performs one ctx-bound poll request and decodes the payload.
func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string) (map[string]interface{}, error) {
form := url.Values{}
form.Set("action", "poll")
form.Set("device_code", deviceCode)
req, err := http.NewRequestWithContext(ctx, "POST", appRegistrationEndpoint(brand), strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("poll request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("poll network error: %w", err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("poll read error: %w", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("poll parse error: %w", err)
}
return data, nil
}
// RegisterAppWithDiscovery polls for credentials, mirroring the official SDK
// flow: the first poll and the (at most one) cross-brand switch are immediate,
// non-error responses without complete credentials keep polling, and one
// deadline from the begin expiry bounds all waits and in-flight requests.
// The returned brand is the one the credentials were issued on.
func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, core.LarkBrand, error) {
if errOut == nil {
errOut = io.Discard
}
// Interval and expiry arrive normalized from begin-response parsing
// (normalizedInterval floors them there); the loop trusts them as-is.
interval := resp.Interval
ctx, cancel := context.WithDeadline(ctx,
time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second))
defer cancel()
currentBrand := registrationBootstrapBrand
effectiveBrand := currentBrand
switched := false
waitBeforePoll := false
for {
if waitBeforePoll {
select {
case <-time.After(time.Duration(interval) * time.Second):
case <-ctx.Done():
return nil, effectiveBrand, registrationContextError(ctx)
}
}
waitBeforePoll = true
if ctx.Err() != nil {
return nil, effectiveBrand, registrationContextError(ctx)
}
data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: %v\n", err)
interval = minInt(interval+1, maxPollIntervalSeconds)
continue
}
// A cross-brand tenant report switches the polled domain (once,
// immediately) regardless of the accompanying status — the signal can
// arrive alongside authorization_pending, mirroring the official SDK.
if !switched {
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
if tb := getStr(userInfoRaw, "tenant_brand"); tb != "" {
if actual := core.ParseBrand(tb); actual != currentBrand {
currentBrand = actual
effectiveBrand = actual
switched = true
waitBeforePoll = false
continue
}
}
}
}
errStr := getStr(data, "error")
if errStr == "" {
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
}
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
result.UserInfo = &AppRegUserInfo{
OpenID: getStr(userInfoRaw, "open_id"),
TenantBrand: getStr(userInfoRaw, "tenant_brand"),
}
}
if result.ClientID != "" && result.ClientSecret != "" {
// The issuing domain is authoritative; a contradictory final
// tenant report is a protocol violation, not a brand override.
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" &&
core.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand {
return nil, effectiveBrand, fmt.Errorf("app registration returned credentials with a contradictory tenant brand %q", result.UserInfo.TenantBrand)
}
return result, effectiveBrand, nil
}
// Incomplete credentials without an error: keep polling.
continue
}
switch errStr {
case "authorization_pending":
continue
case "slow_down":
interval = minInt(interval+5, maxPollIntervalSeconds)
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", interval)
continue
case "access_denied":
return nil, effectiveBrand, ErrRegistrationDenied
case "expired_token", "invalid_grant":
return nil, effectiveBrand, ErrRegistrationExpired
}
desc := getStr(data, "error_description")
if desc == "" {
desc = errStr
}
return nil, effectiveBrand, fmt.Errorf("app registration failed: %s", desc)
}
}
+405
View File
@@ -0,0 +1,405 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/smartystreets/goconvey/convey"
)
// jsonResponse builds a canned registration response (transport fakes reuse
// roundTripFunc from device_flow_test.go).
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// Test_BuildVerificationURL verifies that tracking parameters are correctly appended.
func Test_BuildVerificationURL(t *testing.T) {
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
convey.Convey("should add ? separator", t, func() {
convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
convey.So(result, convey.ShouldStartWith, "https://example.com/verify?")
})
})
t.Run("URL已含问号则添加&分隔符", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify?code=abc", "2.0.0")
convey.Convey("should add & separator", t, func() {
convey.So(result, convey.ShouldContainSubstring, "&lpv=2.0.0")
convey.So(result, convey.ShouldContainSubstring, "&ocv=2.0.0")
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
convey.So(result, convey.ShouldNotContainSubstring, "?lpv=")
})
})
}
func TestAppRegistrationEndpoint(t *testing.T) {
cases := []struct {
brand core.LarkBrand
want string
}{
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
}
for _, c := range cases {
if got := appRegistrationEndpoint(c.brand); got != c.want {
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
}
}
}
func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) {
cases := []struct {
brand core.LarkBrand
verificationHost string
}{
{core.BrandFeishu, "open.feishu.cn"},
{core.BrandLark, "open.larksuite.com"},
}
for _, c := range cases {
t.Run(string(c.brand), func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
t.Errorf("begin host = %q, want bootstrap host %q", got, want)
}
return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err)
}
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") {
t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost)
}
})
}
}
// Full Lark routing contract: Lark selects the Lark verification page, while
// registration bootstraps on Feishu and switches only after the tenant signal.
// The Lark credential response omits user_info, so the effective domain must
// still determine the saved brand.
func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
var calls []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
action := r.Form.Get("action")
calls = append(calls, action+"@"+r.URL.Host)
if action == "begin" {
return jsonResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":60,"interval":0}`), nil
}
switch r.URL.Host {
case "accounts.feishu.cn":
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
case "accounts.larksuite.com":
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
}
t.Errorf("unexpected host polled: %s", r.URL.Host)
return jsonResponse(`{}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration error = %v", err)
}
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want {
t.Errorf("verification URL = %q, want %q", got, want)
}
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandLark {
t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, core.BrandLark)
}
if result.ClientID != "cli_x" || result.ClientSecret != "test-secret" {
t.Errorf("credentials = (%q, %q), want (cli_x, test-secret)", result.ClientID, result.ClientSecret)
}
want := []string{"begin@accounts.feishu.cn", "poll@accounts.feishu.cn", "poll@accounts.larksuite.com"}
if len(calls) != len(want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
for i := range want {
if calls[i] != want[i] {
t.Errorf("calls = %v, want %v", calls, want)
break
}
}
}
// Plain path: the bootstrap domain can return complete Feishu credentials in
// one poll, even when user_info is absent.
func TestRegisterAppWithDiscovery_BootstrapBrandSinglePoll(t *testing.T) {
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polls++
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
t.Errorf("poll host = %q, want %q", got, want)
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
_, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandFeishu {
t.Errorf("finalBrand = %q, want %q", finalBrand, core.BrandFeishu)
}
if polls != 1 {
t.Errorf("polls = %d, want 1", polls)
}
}
// The discovery deadline must cancel in-flight requests: the fake transport
// hangs until the request context is done.
func TestRegisterAppWithDiscovery_DeadlineBoundsInFlightRequests(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 1}
start := time.Now()
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if !strings.Contains(err.Error(), "timed out") {
t.Errorf("error = %v, want a timed-out terminal reason", err)
}
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Errorf("discovery not bounded by its deadline: took %v", elapsed)
}
}
// Empty payloads and incomplete same-brand responses are not terminal.
func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
responses := []string{
`{}`,
`{"client_id":"cli_x","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
}
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
body := responses[polls]
polls++
return jsonResponse(body), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if polls != 3 {
t.Errorf("polls = %d, want 3", polls)
}
if result.ClientSecret != "test-secret" || finalBrand != core.BrandFeishu {
t.Errorf("result = (%q, %q), want (test-secret, feishu)", result.ClientSecret, finalBrand)
}
}
// Neither the first poll nor the cross-brand switch waits out the interval
// (a 5s interval would blow the elapsed bound).
func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
var polledHosts []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polledHosts = append(polledHosts, r.URL.Host)
if r.URL.Host == "accounts.feishu.cn" {
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 5, ExpiresIn: 60}
start := time.Now()
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("discovery waited an interval somewhere: took %v", elapsed)
}
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
}
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
}
}
// Denial and expiry map to sentinels; cancellation preserves its cause.
func TestRegisterAppWithDiscovery_TerminalSentinels(t *testing.T) {
deny := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(`{"error":"access_denied"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
_, _, err := RegisterAppWithDiscovery(context.Background(), deny, resp, io.Discard)
if !errors.Is(err, ErrRegistrationDenied) {
t.Errorf("denied err = %v, want ErrRegistrationDenied", err)
}
expired := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(`{"error":"expired_token"}`), nil
})}
_, _, err = RegisterAppWithDiscovery(context.Background(), expired, resp, io.Discard)
if !errors.Is(err, ErrRegistrationExpired) {
t.Errorf("expired err = %v, want ErrRegistrationExpired", err)
}
cancelledCtx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err = RegisterAppWithDiscovery(cancelledCtx, deny, resp, io.Discard)
if !errors.Is(err, context.Canceled) {
t.Errorf("cancelled err = %v, want a context.Canceled cause", err)
}
}
// Begin parsing: expire_in (legacy expires_in fallback), normalization, and
// required device_code.
func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
serve := func(body string) *http.Client {
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(body), nil
})}
}
resp, err := RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("begin error = %v", err)
}
if resp.ExpiresIn != 60 || resp.Interval != 3 {
t.Errorf("parsed (expire=%d, interval=%d), want (60, 3)", resp.ExpiresIn, resp.Interval)
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("legacy begin error = %v", err)
}
if resp.ExpiresIn != 45 || resp.Interval != 5 {
t.Errorf("legacy parsed (expire=%d, interval=%d), want (45, 5 — normalized default)", resp.ExpiresIn, resp.Interval)
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("defaults begin error = %v", err)
}
if resp.ExpiresIn != 600 || resp.Interval != 5 {
t.Errorf("defaults parsed (expire=%d, interval=%d), want (600, 5)", resp.ExpiresIn, resp.Interval)
}
if _, err := RequestAppRegistration(context.Background(),
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
t.Error("missing device_code: expected error, got nil")
}
}
// A tenant signal arriving alongside authorization_pending must still switch
// the polled domain (the official SDK checks the signal before the error).
func TestRegisterAppWithDiscovery_PendingWithTenantSignalSwitches(t *testing.T) {
var polledHosts []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polledHosts = append(polledHosts, r.URL.Host)
if r.URL.Host == "accounts.feishu.cn" {
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
}
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
}
}
// Polling has no attempt cap: only the expiry budget terminates the loop.
func TestRegisterAppWithDiscovery_NoAttemptCap(t *testing.T) {
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polls++
if polls <= 250 {
return jsonResponse(`{"error":"authorization_pending"}`), nil
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 30}
result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil (no attempts cap)", err)
}
if polls != 251 || result.ClientSecret != "test-secret" {
t.Errorf("polls = %d (want 251), secret = %q", polls, result.ClientSecret)
}
}
// A final tenant report contradicting the issuing domain is a protocol
// violation, not a brand override: the saved brand must never diverge from
// the domain that issued the credentials.
func TestRegisterAppWithDiscovery_ContradictoryFinalBrandFails(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "accounts.feishu.cn" {
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
}
// The lark domain issues credentials but reports a feishu tenant.
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"tenant_brand":"feishu"}}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err == nil || !strings.Contains(err.Error(), "contradictory tenant brand") {
t.Errorf("err = %v, want contradictory-tenant-brand protocol error", err)
}
}
// A cancelled body read during begin must keep its context cause so the
// command layer classifies it as a cancellation, not an API failure.
func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(&errReader{err: context.Canceled}),
Header: make(http.Header),
}, nil
})}
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want a context.Canceled cause", err)
}
}
type errReader struct{ err error }
func (r *errReader) Read([]byte) (int, error) { return 0, r.err }
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
"github.com/larksuite/cli/internal/keychain"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// logHTTPResponse logs the HTTP response details for an authentication request.
// It extracts the request path, status code, and x-tt-logid from the given HTTP response.
func logHTTPResponse(resp *http.Response) {
if resp == nil {
return
}
path := "missing"
if resp.Request != nil && resp.Request.URL != nil {
path = resp.Request.URL.Path
}
keychain.LogAuthResponse(path, resp.StatusCode, resp.Header.Get("x-tt-logid"))
}
// logSDKResponse logs the SDK response details for an authentication request.
// It extracts the status code and x-tt-logid from the given API response object.
func logSDKResponse(path string, apiResp *larkcore.ApiResp) {
if path == "" {
path = "missing"
}
if apiResp == nil {
keychain.LogAuthResponse(path, 0, "")
return
}
keychain.LogAuthResponse(path, apiResp.StatusCode, apiResp.Header.Get("x-tt-logid"))
}
+298
View File
@@ -0,0 +1,298 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
)
// DeviceAuthResponse is the response from the device authorization endpoint.
type DeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
VerificationUriComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// DeviceFlowTokenData contains the token data from a successful device flow.
type DeviceFlowTokenData struct {
AccessToken string
RefreshToken string
ExpiresIn int
RefreshExpiresIn int
Scope string
}
// DeviceFlowResult is the result of polling the token endpoint.
type DeviceFlowResult struct {
OK bool
Token *DeviceFlowTokenData
Error string
Message string
}
// OAuthEndpoints contains the OAuth endpoint URLs.
type OAuthEndpoints struct {
DeviceAuthorization string
Revoke string
Token string
}
// ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand.
func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand)
return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
Revoke: ep.Accounts + PathOAuthRevoke,
Token: ep.Open + PathOAuthTokenV2,
}
}
// RequestDeviceAuthorization requests a device authorization code.
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
if errOut == nil {
errOut = io.Discard
}
endpoints := ResolveOAuthEndpoints(brand)
if !strings.Contains(scope, "offline_access") {
if scope != "" {
scope = scope + " offline_access"
} else {
scope = "offline_access"
}
}
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
form := url.Values{}
form.Set("client_id", appId)
form.Set("scope", scope)
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Basic "+basicAuth)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("Device authorization failed: read body: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("Device authorization failed: HTTP %d response not JSON", resp.StatusCode)
}
_, hasError := data["error"]
if resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("Device authorization failed: %s", msg)
}
expiresIn := getInt(data, "expires_in", 240)
interval := getInt(data, "interval", 5)
verificationUri := getStr(data, "verification_uri")
verificationUriComplete := getStr(data, "verification_uri_complete")
if verificationUriComplete == "" {
verificationUriComplete = verificationUri
}
return &DeviceAuthResponse{
DeviceCode: getStr(data, "device_code"),
UserCode: getStr(data, "user_code"),
VerificationUri: verificationUri,
VerificationUriComplete: verificationUriComplete,
ExpiresIn: expiresIn,
Interval: interval,
}, nil
}
// PollDeviceToken polls the token endpoint until authorization completes or times out.
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
if errOut == nil {
errOut = io.Discard
}
if interval < 1 {
interval = 5
}
const maxPollInterval = 60
const maxPollAttempts = 600
endpoints := ResolveOAuthEndpoints(brand)
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
}
select {
case <-time.After(time.Duration(currentInterval) * time.Second):
case <-ctx.Done():
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("device_code", deviceCode)
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll network error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll read error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll parse error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
errStr := getStr(data, "error")
if errStr == "" && getStr(data, "access_token") != "" {
fmt.Fprintf(errOut, "[lark-cli] device-flow: token response received\n")
refreshToken := getStr(data, "refresh_token")
tokenExpiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)
if refreshToken == "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: no refresh_token in response\n")
refreshExpiresIn = tokenExpiresIn
}
return &DeviceFlowResult{
OK: true,
Token: &DeviceFlowTokenData{
AccessToken: getStr(data, "access_token"),
RefreshToken: refreshToken,
ExpiresIn: tokenExpiresIn,
RefreshExpiresIn: refreshExpiresIn,
Scope: getStr(data, "scope"),
},
}
}
switch errStr {
case "authorization_pending":
continue
case "slow_down":
currentInterval = minInt(currentInterval+5, maxPollInterval)
fmt.Fprintf(errOut, "[lark-cli] device-flow: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
msg := getStr(data, "error_description")
if msg == "" {
msg = "Authorization denied by user"
}
return &DeviceFlowResult{OK: false, Error: "access_denied", Message: msg}
case "expired_token", "invalid_grant":
msg := getStr(data, "error_description")
if msg == "" {
msg = "Device code expired, please try again"
}
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: msg}
}
desc := getStr(data, "error_description")
if desc == "" {
desc = errStr
}
if desc == "" {
desc = "Unknown error"
}
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: unexpected error: error=%s, desc=%s\n", errStr, desc)
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: desc}
}
if attempts >= maxPollAttempts {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: max poll attempts (%d) reached\n", maxPollAttempts)
}
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Authorization timed out, please try again"}
}
// helpers
// minInt returns the smaller of a or b.
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// getStr retrieves a string value from a map, returning an empty string if not found or not a string.
func getStr(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// getInt retrieves an integer value from a map, returning a fallback value if not found or not a number.
func getInt(m map[string]interface{}, key string, fallback int) int {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
return fallback
}
+218
View File
@@ -0,0 +1,218 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/keychain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandFeishu)
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
}
// TestResolveOAuthEndpoints_Lark validates endpoints for the Lark brand.
func TestResolveOAuthEndpoints_Lark(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandLark)
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
}
// TestRequestDeviceAuthorization_LogsResponse checks if API responses are logged correctly.
func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
Headers: http.Header{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"device-log-id"},
},
})
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "login", "--device-code", "device-code-secret", "--app-secret=top-secret"}
})
t.Cleanup(restore)
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
if err != nil {
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
}
got := buf.String()
if !strings.Contains(got, "time=2026-04-02T03:04:05Z") {
t.Fatalf("expected time in log, got %q", got)
}
if !strings.Contains(got, "path=missing") {
t.Fatalf("expected path in log, got %q", got)
}
if !strings.Contains(got, "status=200") {
t.Fatalf("expected status=200 in log, got %q", got)
}
if !strings.Contains(got, "x-tt-logid=device-log-id") {
t.Fatalf("expected x-tt-logid in log, got %q", got)
}
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
t.Fatalf("expected cmdline in log, got %q", got)
}
}
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
got := keychain.FormatAuthCmdline([]string{
"lark-cli",
"auth",
"login",
"--device-code", "device-code-secret",
"--app-secret=top-secret",
"--scope", "contact:read",
})
want := "lark-cli auth login ..."
if got != want {
t.Fatalf("formatAuthCmdline() = %q, want %q", got, want)
}
}
// TestLogAuthResponse_IgnoresTypedNilHTTPResponse tests that a typed nil HTTP response is ignored gracefully.
func TestLogAuthResponse_IgnoresTypedNilHTTPResponse(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), nil, nil)
t.Cleanup(restore)
var resp *http.Response
logHTTPResponse(resp)
if got := buf.String(); got != "" {
t.Fatalf("expected no log output, got %q", got)
}
}
// TestLogAuthResponse_HandlesNilSDKResponse verifies that a nil SDK response is handled without panicking.
func TestLogAuthResponse_HandlesNilSDKResponse(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "status", "--verify"}
})
t.Cleanup(restore)
logSDKResponse(PathUserInfoV1, nil)
got := buf.String()
if !strings.Contains(got, "path="+PathUserInfoV1) {
t.Fatalf("expected sdk path in log, got %q", got)
}
if !strings.Contains(got, "status=0") {
t.Fatalf("expected zero status in log, got %q", got)
}
}
func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "login", "--device-code", "secret"}
})
t.Cleanup(restore)
keychain.LogAuthError("keychain", "Set", fmt.Errorf("keychain Set error: %w", http.ErrUseLastResponse))
got := buf.String()
if !strings.Contains(got, "auth-error") {
t.Fatalf("expected auth-error log entry, got %q", got)
}
if !strings.Contains(got, "component=keychain") {
t.Fatalf("expected component in log, got %q", got)
}
if !strings.Contains(got, "op=Set") {
t.Fatalf("expected op in log, got %q", got)
}
if !strings.Contains(got, "error=\"keychain Set error: net/http: use last response\"") {
t.Fatalf("expected quoted error in log, got %q", got)
}
if !strings.Contains(got, "cmdline=lark-cli auth login ...") {
t.Fatalf("expected truncated cmdline in log, got %q", got)
}
}
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
t.Parallel()
var requests atomic.Int32
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}),
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}
if result.Message != "Polling was cancelled" {
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
}
if got := requests.Load(); got != 0 {
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
const (
needUserAuthorizationMarker = "need_user_authorization"
)
// TokenRetryCodes contains error codes that allow retry after token refresh.
var TokenRetryCodes = map[int]bool{
output.LarkErrTokenInvalid: true,
output.LarkErrTokenExpired: true,
}
// NeedAuthorizationError is the sentinel preserved in the Cause chain of the
// typed missing-UAT error so existing errors.As(&NeedAuthorizationError{})
// consumers keep matching after the construction site moved to the typed
// taxonomy. It is never surfaced on the wire on its own.
type NeedAuthorizationError struct {
UserOpenId string
}
// Error returns the error message for NeedAuthorizationError.
func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
}
// NewNeedUserAuthorizationError builds the typed *errs.AuthenticationError
// returned when no valid UAT exists for userOpenID. The Message keeps the
// need_user_authorization marker, the Hint converges on the same auth-login
// recovery vocabulary as the token-missing surface in internal/client, and the
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
WithUserOpenID(userOpenID).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure. It matches the legacy *NeedAuthorizationError sentinel, which is
// preserved in the Cause chain of the typed missing-UAT error, so errors.As
// traverses into the typed *errs.AuthenticationError as well.
func IsNeedUserAuthorizationError(err error) bool {
if err == nil {
return false
}
var needAuthErr *NeedAuthorizationError
return errors.As(err, &needAuthErr)
}
// SecurityPolicyError is preserved as a Go type alias so existing
// errors.As(&SecurityPolicyError{}) consumers (cmd/root.go etc.) keep working.
// The concrete struct lives in errs/types.go.
type SecurityPolicyError = errs.SecurityPolicyError
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/errs"
)
func TestIsNeedUserAuthorizationError(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if IsNeedUserAuthorizationError(nil) {
t.Fatal("expected nil error not to match")
}
})
t.Run("direct auth error", func(t *testing.T) {
if !IsNeedUserAuthorizationError(&NeedAuthorizationError{UserOpenId: "u_1"}) {
t.Fatal("expected direct NeedAuthorizationError to match")
}
})
t.Run("typed missing-UAT error carries sentinel in cause", func(t *testing.T) {
// The typed constructor preserves the legacy sentinel in the Cause
// chain, so errors.As traverses into it.
if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) {
t.Fatal("expected typed missing-UAT error to match via its cause chain")
}
})
t.Run("other error", func(t *testing.T) {
err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout")
if IsNeedUserAuthorizationError(err) {
t.Fatal("expected unrelated error not to match")
}
})
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
// Common authentication paths used for logging and API calls.
const (
// PathDeviceAuthorization is the endpoint for device authorization.
PathDeviceAuthorization = "/oauth/v1/device_authorization"
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
PathOAuthRevoke = "/oauth/v1/revoke"
// PathAppRegistration is the endpoint for application registration.
PathAppRegistration = "/oauth/v1/app/registration"
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"
// PathUserInfoV1 is the endpoint for fetching user information.
PathUserInfoV1 = "/open-apis/authen/v1/user_info"
// PathApplicationInfoV6Prefix is the prefix endpoint for fetching application info.
PathApplicationInfoV6Prefix = "/open-apis/application/v6/applications/"
)
// ApplicationInfoPath returns the full API path for querying an application's information.
func ApplicationInfoPath(appId string) string {
return PathApplicationInfoV6Prefix + appId
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// RevokeToken revokes a previously issued OAuth token.
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
endpoints := ResolveOAuthEndpoints(brand)
form := url.Values{}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
form.Set("token", token)
if tokenTypeHint != "" {
form.Set("token_type_hint", tokenTypeHint)
}
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
}
if resp.StatusCode >= 400 {
return revokeHTTPStatusError(resp.StatusCode, body)
}
if len(body) == 0 {
return nil
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil
}
if code := getInt(data, "code", 0); code != 0 {
msg := getStr(data, "msg")
if msg == "" {
msg = getStr(data, "message")
}
if msg == "" {
msg = "unknown error"
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
WithCode(code).
WithCause(errors.New(msg))
}
if errStr := getStr(data, "error"); errStr != "" {
msg := getStr(data, "error_description")
if msg == "" {
msg = errStr
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
WithCause(errors.New(msg))
}
return nil
}
func revokeHTTPStatusError(status int, body []byte) error {
msg := formatOAuthErrorBody(body)
cause := errors.New(strings.TrimSpace(string(body)))
if strings.TrimSpace(string(body)) == "" {
cause = errors.New(msg)
}
if status >= http.StatusInternalServerError {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithRetryable().
WithCause(cause)
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithCause(cause)
}
func formatOAuthErrorBody(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return "empty response"
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return trimmed
}
if msg := getStr(data, "error_description"); msg != "" {
return msg
}
if msg := getStr(data, "msg"); msg != "" {
return msg
}
if msg := getStr(data, "message"); msg != "" {
return msg
}
if msg := getStr(data, "error"); msg != "" {
return msg
}
return trimmed
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type errReadCloser struct {
err error
}
func (r errReadCloser) Read(_ []byte) (int, error) {
return 0, r.err
}
func (r errReadCloser) Close() error {
return nil
}
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
stub := &httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_a" &&
values.Get("client_secret") == "secret_b" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
}
reg.Register(stub)
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err != nil {
t.Fatalf("RevokeToken() error = %v", err)
}
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Fatalf("Content-Type = %q", got)
}
}
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
sentinel := errors.New("transport down")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, sentinel
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %#v, want network/transport", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
}
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Status: 400,
Body: map[string]interface{}{"error": "invalid_token"},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 400 {
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
}
if !strings.Contains(err.Error(), "invalid_token") {
t.Fatalf("expected invalid_token error, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"code": 12345,
"msg": "invalid revoke state",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 12345 {
t.Fatalf("problem = %#v, want api error with code 12345", p)
}
if !strings.Contains(err.Error(), "invalid revoke state") {
t.Fatalf("expected oauth error message, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"error": "invalid_token",
"error_description": "token already expired",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("problem = %#v, want api error", p)
}
if !strings.Contains(err.Error(), "token already expired") {
t.Fatalf("expected oauth error_description, got %v", err)
}
}
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
sentinel := errors.New("read failed")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: errReadCloser{err: sentinel},
Header: make(http.Header),
}, nil
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
if !strings.Contains(err.Error(), "token revoke read error") {
t.Fatalf("expected read error message, got %v", err)
}
if _, ok := err.(*errs.InternalError); !ok {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import "strings"
// MissingScopes returns the elements of required that are absent from storedScope.
// storedScope is a space-separated list of granted scope strings (as stored in the token).
func MissingScopes(storedScope string, required []string) []string {
granted := make(map[string]bool)
for _, s := range strings.Fields(storedScope) {
granted[s] = true
}
var missing []string
for _, s := range required {
if !granted[s] {
missing = append(missing, s)
}
}
return missing
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
)
// TestMissingScopes tests the calculation of missing scopes.
func TestMissingScopes(t *testing.T) {
tests := []struct {
name string
storedScope string
required []string
expected []string
}{
{
name: "all matched",
storedScope: "a b c",
required: []string{"a", "b"},
expected: nil,
},
{
name: "partial missing",
storedScope: "a b",
required: []string{"a", "c"},
expected: []string{"c"},
},
{
name: "all missing",
storedScope: "a b",
required: []string{"x", "y"},
expected: []string{"x", "y"},
},
{
name: "empty storedScope",
storedScope: "",
required: []string{"a"},
expected: []string{"a"},
},
{
name: "empty required",
storedScope: "a b",
required: []string{},
expected: nil,
},
{
name: "extra whitespace in storedScope",
storedScope: " a b c ",
required: []string{"b"},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MissingScopes(tt.storedScope, tt.required)
if !sliceEqual(got, tt.expected) {
t.Errorf("MissingScopes(%q, %v) = %v, want %v", tt.storedScope, tt.required, got, tt.expected)
}
})
}
}
// sliceEqual compares two string slices for equality.
func sliceEqual(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"fmt"
"time"
"github.com/larksuite/cli/internal/keychain"
)
// StoredUAToken represents a stored user access token.
type StoredUAToken struct {
UserOpenId string `json:"userOpenId"`
AppId string `json:"appId"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"` // Unix ms
RefreshExpiresAt int64 `json:"refreshExpiresAt"` // Unix ms
Scope string `json:"scope"`
GrantedAt int64 `json:"grantedAt"` // Unix ms
}
const refreshAheadMs = 5 * 60 * 1000 // 5 minutes
// accountKey generates a unique key for an account based on its AppID and UserOpenID.
func accountKey(appId, userOpenId string) string {
return fmt.Sprintf("%s:%s", appId, userOpenId)
}
// MaskToken masks a token for safe logging.
func MaskToken(token string) string {
if len(token) <= 8 {
return "****"
}
return "****" + token[len(token)-4:]
}
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
if err != nil || jsonStr == "" {
return nil
}
var token StoredUAToken
if err := json.Unmarshal([]byte(jsonStr), &token); err != nil {
return nil
}
return &token
}
// SetStoredToken persists a UAT.
func SetStoredToken(token *StoredUAToken) error {
key := accountKey(token.AppId, token.UserOpenId)
data, err := json.Marshal(token)
if err != nil {
return err
}
return keychain.Set(keychain.LarkCliService, key, string(data))
}
// RemoveStoredToken removes a stored UAT.
func RemoveStoredToken(appId, userOpenId string) error {
return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId))
}
// TokenStatus determines the freshness of a stored token.
func TokenStatus(token *StoredUAToken) string {
now := time.Now().UnixMilli()
if now < token.ExpiresAt-refreshAheadMs {
return "valid"
}
if now < token.RefreshExpiresAt {
return "needs_refresh"
}
return "expired"
}
+238
View File
@@ -0,0 +1,238 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/transport"
)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
// and checks for security policy errors.
type SecurityPolicyTransport struct {
Base http.RoundTripper
}
// base returns the underlying RoundTripper or http.DefaultTransport if nil.
func (t *SecurityPolicyTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base().RoundTrip(req)
if err != nil {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
return nil, err
}
if resp == nil || resp.Body == nil {
return resp, nil
}
// Only process JSON responses to avoid memory spikes on large files
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if !strings.Contains(contentType, "application/json") {
return resp, nil
}
// Read up to 64KB of the body to check for security policy errors
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("failed to read response body in security transport: %w", err)
}
// Restore the body so it can be read by the caller, preserving streaming capability
resp.Body = struct {
io.Reader
io.Closer
}{
io.MultiReader(bytes.NewReader(bodyBytes), resp.Body),
resp.Body,
}
// Try to parse it as JSON
var result map[string]interface{}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return resp, nil
}
// 1. Try to handle as MCP (JSON-RPC) format first
if err := t.tryHandleMCPResponse(result); err != nil {
resp.Body.Close()
return nil, err
}
// 2. Try to handle as OpenAPI error format
if err := t.tryHandleOAPIResponse(result); err != nil {
resp.Body.Close()
return nil, err
}
return resp, nil
}
// tryHandleMCPResponse attempts to parse a JSON-RPC (MCP) formatted error
// response coming back from a remote server (this transport is installed on
// lark-cli's outbound HTTP client; the bodies it inspects are produced by the
// remote, not by lark-cli itself).
//
// Observed production shape from the MCP gateway — Lark code in the outer
// `error.code` slot, hint under `data.cli_hint`:
//
// {"jsonrpc": "2.0", "id": 1,
// "error": {"code": 21000, "message": "...",
// "data": {"challenge_url": "...", "cli_hint": "..."}}}
//
// The parser also accepts a JSON-RPC-canonical shape (outer `error.code`
// carrying the JSON-RPC status like -32603, Lark code under `error.data.code`,
// hint under `data.hint`) so a future server-side migration to that layout
// would not silently drop policy detection. The Lark code is looked up in the
// central code registry; the hint key is read from `data.hint` first and
// falls back to `data.cli_hint`.
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
errMap, ok := result["error"].(map[string]interface{})
if !ok {
return nil
}
dataMap, _ := errMap["data"].(map[string]interface{})
// Try data.code first (shape B); fall back to outer error.code (shape A).
code := 0
if dataMap != nil {
code = getInt(dataMap, "code", 0)
}
if code == 0 {
code = getInt(errMap, "code", 0)
}
meta, ok := errclass.LookupCodeMeta(code)
if !ok || meta.Category != errs.CategoryPolicy {
return nil
}
if dataMap == nil {
return nil
}
// Clean up backticks and spaces from challenge_url
challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `")
// Read `hint` first; fall back to `cli_hint` so either spelling surfaces.
cliHint := getStr(dataMap, "hint")
if cliHint == "" {
cliHint = getStr(dataMap, "cli_hint")
}
msg := getStr(errMap, "message")
if challengeUrl != "" || cliHint != "" {
// Security validation for challengeUrl
if challengeUrl != "" && !isValidChallengeURL(challengeUrl) {
challengeUrl = ""
}
if challengeUrl != "" || cliHint != "" {
return &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
}
}
}
return nil
}
// tryHandleOAPIResponse attempts to parse a standard Lark OpenAPI formatted error response.
func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error {
// 1. Extract code
code := getInt(result, "code", 0)
// If code is 0, check if it's already in our error format {"error": {"code": 21000, ...}, "ok": false}
if code == 0 {
if errMap, ok := result["error"].(map[string]interface{}); ok {
code = getInt(errMap, "code", 0)
}
}
// 2. Check if it's a security policy error (consult central code registry)
meta, ok := errclass.LookupCodeMeta(code)
if !ok || meta.Category != errs.CategoryPolicy {
return nil
}
// 3. Extract details
var challengeUrl, cliHint, msg string
if dataMap, ok := result["data"].(map[string]interface{}); ok {
// Standard OAPI format
challengeUrl = getStr(dataMap, "challenge_url")
cliHint = getStr(dataMap, "cli_hint")
msg = getStr(result, "msg")
} else if errMap, ok := result["error"].(map[string]interface{}); ok {
// Already formatted error format (e.g. from internal API or CLI output)
challengeUrl = getStr(errMap, "challenge_url")
cliHint = getStr(errMap, "hint")
msg = getStr(errMap, "message")
}
// 4. Print and exit if we have enough info
if msg != "" || challengeUrl != "" || cliHint != "" {
// Security validation for challengeUrl
if challengeUrl != "" && !isValidChallengeURL(challengeUrl) {
challengeUrl = ""
}
if msg != "" || challengeUrl != "" || cliHint != "" {
return &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
}
}
}
return nil
}
// isValidChallengeURL checks if the given URL is a valid challenge URL.
func isValidChallengeURL(rawURL string) bool {
if rawURL == "" {
return false
}
u, err := url.Parse(rawURL)
if err != nil {
return false
}
// 1. Must be https
if u.Scheme != "https" {
return false
}
return true
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
// This shape is forward-compat for a future server-side migration to the
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
// for the shape observed in production today.
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"error": map[string]interface{}{
"code": -32603, // JSON-RPC internal error
"message": "challenge required",
"data": map[string]interface{}{
"code": 21000, // Lark code for challenge_required
"type": "policy",
"subtype": "challenge_required",
"challenge_url": "https://example.com/challenge",
"hint": "please complete the challenge in your browser",
},
},
}
got := transport.tryHandleMCPResponse(result)
var spErr *errs.SecurityPolicyError
if !errors.As(got, &spErr) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
}
if spErr.Code != 21000 {
t.Errorf("Code = %d, want 21000", spErr.Code)
}
if spErr.Subtype != errs.SubtypeChallengeRequired {
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
}
if spErr.ChallengeURL != "https://example.com/challenge" {
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
}
if spErr.Hint != "please complete the challenge in your browser" {
t.Errorf("Hint = %q", spErr.Hint)
}
}
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
// in production from the MCP gateway: the Lark code sits in the outer
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
// The transport's outer-code fallback path must recognise the policy code and
// surface the typed error with the hint promoted.
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"error": map[string]interface{}{
"code": 21001, // outer slot carries the Lark code
"message": "access denied",
"data": map[string]interface{}{
"challenge_url": "https://example.com/c",
"cli_hint": "contact admin",
},
},
}
got := transport.tryHandleMCPResponse(result)
var spErr *errs.SecurityPolicyError
if !errors.As(got, &spErr) {
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
}
if spErr.Subtype != errs.SubtypeAccessDenied {
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
}
// `cli_hint` must surface when `hint` is absent.
if spErr.Hint != "contact admin" {
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
}
}
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
// nil (passes through) when the Lark code does not classify as
// CategoryPolicy — keeps regular API errors out of the security-policy path.
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
t.Parallel()
transport := &SecurityPolicyTransport{}
result := map[string]interface{}{
"error": map[string]interface{}{
"code": -32603,
"message": "permission denied",
"data": map[string]interface{}{
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
"type": "authorization",
},
},
}
if err := transport.tryHandleMCPResponse(result); err != nil {
t.Fatalf("expected nil (non-policy code), got %v", err)
}
}
+317
View File
@@ -0,0 +1,317 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/gofrs/flock"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/vfs"
)
var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
// sanitizeID replaces empty IDs with "default" to prevent file path issues.
func sanitizeID(id string) string {
return safeIDChars.ReplaceAllString(id, "_")
}
// UATCallOptions contains options for UAT API calls.
type UATCallOptions struct {
UserOpenId string
AppId string
AppSecret string
Domain core.LarkBrand
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
}
// UATStatus represents the status of a user access token.
type UATStatus struct {
Authorized bool `json:"authorized"`
UserOpenId string `json:"userOpenId"`
Scope string `json:"scope,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
GrantedAt int64 `json:"grantedAt,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
// NewUATCallOptions creates UATCallOptions from a CLI config.
func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
if errOut == nil {
errOut = os.Stderr
}
return UATCallOptions{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
ErrOut: errOut,
}
}
var refreshLocks sync.Map
// GetValidAccessToken obtains a valid access token for the given user.
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
status := TokenStatus(stored)
if status == "valid" {
return stored.AccessToken, nil
}
if status == "needs_refresh" {
refreshed, err := refreshWithLock(httpClient, opts, stored)
if err != nil {
return "", err
}
if refreshed == nil {
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
return refreshed.AccessToken, nil
}
// expired
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
} else {
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
// refreshWithLock acquires a file lock before attempting to refresh the token.
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
// 1. Process-level lock (prevents multiple goroutines in the same process)
done := make(chan struct{})
if existing, loaded := refreshLocks.LoadOrStore(key, done); loaded {
// Another goroutine is already refreshing; wait for it
if ch, ok := existing.(chan struct{}); ok {
<-ch
} else {
// fallback in case of unexpected type
refreshLocks.Delete(key)
}
return GetStoredToken(opts.AppId, opts.UserOpenId), nil
}
// We own the process lock; done is the channel stored in the map
defer func() {
close(done)
refreshLocks.Delete(key)
}()
// 2. Cross-process lock using flock
// We use the same underlying storage directory resolution as keychain_other.go
// to ensure locks are isolated properly alongside other sensitive data.
configDir := core.GetConfigDir()
lockDir := filepath.Join(configDir, "locks")
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}
safeAppId := sanitizeID(opts.AppId)
safeUserOpenId := sanitizeID(opts.UserOpenId)
lockFile := filepath.Join(lockDir, fmt.Sprintf("refresh_%s_%s.lock", safeAppId, safeUserOpenId))
fileLock := flock.New(lockFile)
// Try to acquire the lock, wait if necessary
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
locked, err := fileLock.TryLockContext(ctx, 500*time.Millisecond)
if err != nil {
return nil, fmt.Errorf("failed to acquire cross-process lock: %w", err)
}
if !locked {
return nil, fmt.Errorf("timeout waiting for cross-process lock")
}
defer fileLock.Unlock()
// 3. Double-checked locking: Check if another process has already refreshed the token
freshStored := GetStoredToken(opts.AppId, opts.UserOpenId)
if freshStored != nil {
status := TokenStatus(freshStored)
if status == "valid" {
// Another process refreshed it, we can just use the new token
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
}
}
// 4. Actually perform the refresh
return doRefreshToken(httpClient, opts, stored)
}
// doRefreshToken performs the actual HTTP request to refresh the token.
func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
errOut := opts.ErrOut
if errOut == nil {
errOut = os.Stderr
}
now := time.Now().UnixMilli()
if now >= stored.RefreshExpiresAt {
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err)
}
return nil, nil
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
form.Set("client_secret", opts.AppSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("token refresh read error: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("token refresh parse error: %w", err)
}
return data, nil
}
data, err := callEndpoint()
if err != nil {
return nil, err
}
code := getInt(data, "code", -1)
meta, metaOK := errclass.LookupCodeMeta(code)
if metaOK && meta.Category == errs.CategoryPolicy {
challengeUrl := getStr(data, "challenge_url")
cliHint := getStr(data, "cli_hint")
msg := getStr(data, "error_description")
return nil, &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
}
}
errStr := getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
// Retryable server error: retry once, then clear token on second failure.
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
data, err = callEndpoint()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
code = getInt(data, "code", -1)
errStr = getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
// Retry succeeded, fall through to parse token below.
} else {
// All other errors: clear token, require re-authorization.
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
}
accessToken := getStr(data, "access_token")
if accessToken == "" {
return nil, fmt.Errorf("Token refresh returned no access_token")
}
refreshToken := getStr(data, "refresh_token")
if refreshToken == "" {
refreshToken = stored.RefreshToken
}
expiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0)
refreshExpiresAt := stored.RefreshExpiresAt
if refreshExpiresIn > 0 {
refreshExpiresAt = now + int64(refreshExpiresIn)*1000
}
scope := getStr(data, "scope")
if scope == "" {
scope = stored.Scope
}
updated := &StoredUAToken{
UserOpenId: stored.UserOpenId,
AppId: opts.AppId,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: now + int64(expiresIn)*1000,
RefreshExpiresAt: refreshExpiresAt,
Scope: scope,
GrantedAt: stored.GrantedAt,
}
if err := SetStoredToken(updated); err != nil {
return nil, err
}
return updated, nil
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"testing"
"github.com/larksuite/cli/internal/core"
)
// TestNewUATCallOptions validates the extraction of options from CLI config.
func TestNewUATCallOptions(t *testing.T) {
cfg := &core.CliConfig{
AppID: "app123",
AppSecret: "secret",
Brand: core.BrandLark,
UserOpenId: "ou_test",
}
errOut := &bytes.Buffer{}
opts := NewUATCallOptions(cfg, errOut)
if opts.AppId != "app123" {
t.Errorf("AppId = %q, want app123", opts.AppId)
}
if opts.AppSecret != "secret" {
t.Errorf("AppSecret = %q, want secret", opts.AppSecret)
}
if opts.Domain != core.BrandLark {
t.Errorf("Domain = %q, want lark", opts.Domain)
}
if opts.UserOpenId != "ou_test" {
t.Errorf("UserOpenId = %q, want ou_test", opts.UserOpenId)
}
if opts.ErrOut != errOut {
t.Error("ErrOut not set correctly")
}
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// VerifyUserToken calls /authen/v1/user_info to confirm the token is accepted server-side.
// Returns nil on success or an error describing why the server rejected the token.
func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error {
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: PathUserInfoV1,
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
}, larkcore.WithUserAccessToken(accessToken))
if err != nil {
return err
}
logSDKResponse(PathUserInfoV1, apiResp)
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if resp.Code != 0 {
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"context"
"log"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/keychain"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/httpmock"
)
// TestVerifyUserToken_TransportError verifies handling of underlying transport errors.
func TestVerifyUserToken_TransportError(t *testing.T) {
reg := &httpmock.Registry{}
// Register no stubs — any request will fail with "no stub" error
sdk := lark.NewClient("test-app", "test-secret",
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpmock.NewClient(reg)),
)
err := VerifyUserToken(context.Background(), sdk, "test-token")
if err == nil {
t.Fatal("expected error from transport failure, got nil")
}
}
// TestVerifyUserToken validates normal and error response paths of the user token validation.
func TestVerifyUserToken(t *testing.T) {
tests := []struct {
name string
body interface{}
wantErr bool
errSubstr string
wantLog bool
}{
{
name: "success",
body: map[string]interface{}{"code": 0, "msg": "ok"},
wantErr: false,
wantLog: true,
},
{
name: "token invalid",
body: map[string]interface{}{"code": 99991668, "msg": "invalid token"},
wantErr: true,
errSubstr: "[99991668]",
wantLog: true,
},
{
name: "non-JSON response",
body: "not json",
wantErr: true,
errSubstr: "invalid character",
wantLog: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "GET",
URL: PathUserInfoV1,
Body: tt.body,
Headers: http.Header{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"verify-log-id"},
},
})
sdk := lark.NewClient("test-app", "test-secret",
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpmock.NewClient(reg)),
)
var buf bytes.Buffer
restore := keychain.SetAuthLogHooksForTest(log.New(&buf, "", 0), func() time.Time {
return time.Date(2026, 4, 2, 3, 4, 5, 0, time.UTC)
}, func() []string {
return []string{"lark-cli", "auth", "status"}
})
t.Cleanup(restore)
err := VerifyUserToken(context.Background(), sdk, "test-token")
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errSubstr)
}
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
got := buf.String()
if tt.wantLog {
if !strings.Contains(got, "path="+PathUserInfoV1) {
t.Fatalf("expected path in log, got %q", got)
}
if !strings.Contains(got, "status=200") {
t.Fatalf("expected status=200 in log, got %q", got)
}
if !strings.Contains(got, "x-tt-logid=verify-log-id") {
t.Fatalf("expected x-tt-logid in log, got %q", got)
}
if !strings.Contains(got, "cmdline=lark-cli auth status") {
t.Fatalf("expected cmdline in log, got %q", got)
}
} else if got != "" {
t.Fatalf("expected no log output, got %q", got)
}
})
}
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
// AuditParams holds parameters for AssertSecurePath.
type AuditParams struct {
TargetPath string
Label string // e.g. "secrets.providers.vault.command"
TrustedDirs []string
AllowInsecurePath bool
AllowReadableByOthers bool
AllowSymlinkPath bool
}
// AssertSecurePath verifies that a file/command path is safe for use with
// OpenClaw SecretRef resolution. On success it returns the effective path
// (the symlink target, if the input was a symlink and allowed).
//
// The check is a short, ordered pipeline — each step below is both a read of
// the contract and a pointer to the helper that enforces it.
func AssertSecurePath(params AuditParams) (string, error) {
target := params.TargetPath
label := params.Label
if err := requireAbsolutePath(target, label); err != nil {
return "", err
}
linfo, err := lstatNonDir(target, label)
if err != nil {
return "", err
}
effectivePath, err := resolveSymlinkIfAllowed(target, linfo, params)
if err != nil {
return "", err
}
if err := requireInTrustedDirs(effectivePath, params.TrustedDirs, label); err != nil {
return "", err
}
if params.AllowInsecurePath {
return effectivePath, nil
}
if err := auditFilePermissions(effectivePath, params.AllowReadableByOthers, label); err != nil {
return "", err
}
if err := checkOwnerUID(effectivePath, label); err != nil {
return "", err
}
return effectivePath, nil
}
// requireAbsolutePath rejects relative paths; relative paths would depend on
// the process cwd and defeat the point of a static audit. Shell-style
// shortcuts like `~` are home-relative, not cwd-relative — they are an
// orthogonal concern and the audit is intentionally Go-stdlib strict here.
// Callers that accept user-authored config (e.g. resolveFileRef) must
// pre-resolve any such shortcuts before passing the path in.
func requireAbsolutePath(target, label string) error {
if !filepath.IsAbs(target) {
return fmt.Errorf("%s: path must be absolute, got %q", label, target)
}
return nil
}
// lstatNonDir stats the path without following symlinks, rejecting
// directories. Returns the stat info for downstream steps to reuse.
func lstatNonDir(target, label string) (fs.FileInfo, error) {
info, err := vfs.Lstat(target)
if err != nil {
return nil, fmt.Errorf("%s: cannot stat %q: %w", label, target, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%s: path %q is a directory, not a file", label, target)
}
return info, nil
}
// resolveSymlinkIfAllowed resolves a symlink to its target when
// params.AllowSymlinkPath is true, or rejects it otherwise. When the input
// is not a symlink, target is returned unchanged. A symlink that points to
// another symlink is rejected so callers only deal with a single hop.
func resolveSymlinkIfAllowed(target string, linfo fs.FileInfo, params AuditParams) (string, error) {
if linfo.Mode()&os.ModeSymlink == 0 {
return target, nil
}
if !params.AllowSymlinkPath {
return "", fmt.Errorf("%s: path %q is a symlink (not allowed)", params.Label, target)
}
resolved, err := vfs.EvalSymlinks(target)
if err != nil {
return "", fmt.Errorf("%s: cannot resolve symlink %q: %w", params.Label, target, err)
}
rinfo, err := vfs.Lstat(resolved)
if err != nil {
return "", fmt.Errorf("%s: cannot stat resolved path %q: %w", params.Label, resolved, err)
}
if rinfo.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("%s: resolved path %q is still a symlink", params.Label, resolved)
}
return resolved, nil
}
// requireInTrustedDirs enforces that effectivePath lives under one of the
// caller-declared trusted directories, if any were declared. An empty
// trustedDirs list disables the check.
func requireInTrustedDirs(effectivePath string, trustedDirs []string, label string) error {
if len(trustedDirs) == 0 {
return nil
}
cleaned := filepath.Clean(effectivePath)
for _, dir := range trustedDirs {
cleanDir := filepath.Clean(dir)
if cleaned == cleanDir || strings.HasPrefix(cleaned, cleanDir+"/") {
return nil
}
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
+363
View File
@@ -0,0 +1,363 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestAssertSecurePath_NonAbsolutePath(t *testing.T) {
_, err := AssertSecurePath(AuditParams{
TargetPath: "relative/path.txt",
Label: "test",
AllowInsecurePath: true,
})
if err == nil {
t.Fatal("expected error for non-absolute path, got nil")
}
want := fmt.Sprintf("test: path must be absolute, got %q", "relative/path.txt")
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestAssertSecurePath_FileDoesNotExist(t *testing.T) {
nonexistent := filepath.Join(t.TempDir(), "nonexistent.txt")
_, err := AssertSecurePath(AuditParams{
TargetPath: nonexistent,
Label: "test",
AllowInsecurePath: true,
})
if err == nil {
t.Fatal("expected error for non-existent file, got nil")
}
wantPrefix := fmt.Sprintf("test: cannot stat %q: ", nonexistent)
if !strings.HasPrefix(err.Error(), wantPrefix) {
t.Errorf("error = %q, want prefix %q", err.Error(), wantPrefix)
}
}
func TestAssertSecurePath_ValidAbsolutePath(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "valid.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
AllowInsecurePath: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}
func TestAssertSecurePath_WorldWritable_Rejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission tests not applicable on Windows")
}
dir := t.TempDir()
p := filepath.Join(dir, "insecure.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
if err := os.Chmod(p, 0o666); err != nil {
t.Fatalf("chmod: %v", err)
}
_, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
AllowInsecurePath: false,
AllowReadableByOthers: true, // only test writable check
})
if err == nil {
t.Fatal("expected error for world-writable file, got nil")
}
want := fmt.Sprintf("test: path %q is world-writable (mode 0666)", p)
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestAssertSecurePath_AllowInsecurePath_Bypasses(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission tests not applicable on Windows")
}
dir := t.TempDir()
p := filepath.Join(dir, "insecure.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
if err := os.Chmod(p, 0o666); err != nil {
t.Fatalf("chmod: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
AllowInsecurePath: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}
func TestAssertSecurePath_DirectoryRejected(t *testing.T) {
dir := t.TempDir()
_, err := AssertSecurePath(AuditParams{
TargetPath: dir,
Label: "test",
AllowInsecurePath: true,
})
if err == nil {
t.Fatal("expected error for directory path, got nil")
}
want := fmt.Sprintf("test: path %q is a directory, not a file", dir)
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestAssertSecurePath_GroupWritable_Rejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission tests not applicable on Windows")
}
dir := t.TempDir()
p := filepath.Join(dir, "groupw.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Chmod(p, 0o620); err != nil {
t.Fatalf("chmod: %v", err)
}
_, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err == nil {
t.Fatal("expected error for group-writable file, got nil")
}
want := fmt.Sprintf("test: path %q is group-writable (mode 0620)", p)
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestAssertSecurePath_WorldReadable_Rejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission tests not applicable on Windows")
}
dir := t.TempDir()
p := filepath.Join(dir, "worldr.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Chmod(p, 0o604); err != nil {
t.Fatalf("chmod: %v", err)
}
_, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
AllowInsecurePath: false,
AllowReadableByOthers: false,
})
if err == nil {
t.Fatal("expected error for world-readable file, got nil")
}
want := fmt.Sprintf("test: path %q is world-readable (mode 0604)", p)
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestAssertSecurePath_AllowReadableByOthers_Passes(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission tests not applicable on Windows")
}
dir := t.TempDir()
p := filepath.Join(dir, "readable.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Chmod(p, 0o644); err != nil {
t.Fatalf("chmod: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}
func TestAssertSecurePath_OwnerUID_Valid(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("owner UID tests not applicable on Windows")
}
dir := t.TempDir()
p := filepath.Join(dir, "owned.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}
func TestAssertSecurePath_Symlink_Rejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests not applicable on Windows")
}
dir := t.TempDir()
target := filepath.Join(dir, "real.txt")
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
link := filepath.Join(dir, "link.txt")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
_, err := AssertSecurePath(AuditParams{
TargetPath: link,
Label: "test",
AllowSymlinkPath: false,
AllowInsecurePath: true,
})
if err == nil {
t.Fatal("expected error for symlink with AllowSymlinkPath=false, got nil")
}
want := fmt.Sprintf("test: path %q is a symlink (not allowed)", link)
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestAssertSecurePath_Symlink_Allowed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink tests not applicable on Windows")
}
dir := t.TempDir()
target := filepath.Join(dir, "real.txt")
if err := os.WriteFile(target, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
link := filepath.Join(dir, "link.txt")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: link,
Label: "test",
AllowSymlinkPath: true,
AllowInsecurePath: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// On macOS /var → /private/var, so compare resolved paths
wantResolved, err := filepath.EvalSymlinks(target)
if err != nil {
t.Fatalf("EvalSymlinks(target): %v", err)
}
if got != wantResolved {
t.Errorf("got %q, want resolved %q", got, wantResolved)
}
}
func TestAssertSecurePath_TrustedDirs_ExactMatch(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "file.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "test",
TrustedDirs: []string{p},
AllowInsecurePath: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}
func TestAssertSecurePath_TrustedDirs(t *testing.T) {
trustedDir := t.TempDir()
untrustedDir := t.TempDir()
trustedFile := filepath.Join(trustedDir, "secret.txt")
if err := os.WriteFile(trustedFile, []byte("data"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
untrustedFile := filepath.Join(untrustedDir, "secret.txt")
if err := os.WriteFile(untrustedFile, []byte("data"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
// File outside trusted dir should fail
_, err := AssertSecurePath(AuditParams{
TargetPath: untrustedFile,
Label: "test",
TrustedDirs: []string{trustedDir},
AllowInsecurePath: true,
})
if err == nil {
t.Fatal("expected error for file outside trusted dir, got nil")
}
want := fmt.Sprintf("test: path %q is not inside any trusted directory", untrustedFile)
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
// File inside trusted dir should pass
got, err := AssertSecurePath(AuditParams{
TargetPath: trustedFile,
Label: "test",
TrustedDirs: []string{trustedDir},
AllowInsecurePath: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != trustedFile {
t.Errorf("got %q, want %q", got, trustedFile)
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package binding
import (
"fmt"
"os"
"syscall"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID verifies the file is owned by the current user.
func checkOwnerUID(path, label string) error {
stat, err := vfs.Stat(path)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, path, err)
}
sysStat, ok := stat.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("%s: cannot retrieve file owner for %q", label, path)
}
if sysStat.Uid != uint32(os.Getuid()) {
return fmt.Errorf("%s: path %q is owned by uid %d, expected %d",
label, path, sysStat.Uid, os.Getuid())
}
return nil
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package binding
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
func checkOwnerUID(path, label string) error {
return nil
}
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
if _, err := vfs.Stat(effectivePath); err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets-getter.cmd")
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
t.Fatalf("write temp command: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "exec provider command",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"fmt"
"strings"
)
// ReadJSONPointer navigates a parsed JSON value (typically the result of
// json.Unmarshal into interface{}) using an RFC 6901 JSON Pointer string.
//
// Supported pointer format: "/key/subkey/subsubkey".
// An empty pointer ("") returns data as-is.
// RFC 6901 escape sequences: ~1 → /, ~0 → ~.
//
// Limitation: only object (map) traversal is supported. Array index segments
// (e.g., "/channels/0/appId") are not implemented because OpenClaw's
// SecretRef file provider uses object-only paths in practice.
func ReadJSONPointer(data interface{}, pointer string) (interface{}, error) {
if pointer == "" {
return data, nil
}
if !strings.HasPrefix(pointer, "/") {
return nil, fmt.Errorf("json pointer must start with '/' or be empty, got %q", pointer)
}
// Split after the leading "/" and decode each segment.
segments := strings.Split(pointer[1:], "/")
current := data
for i, raw := range segments {
// RFC 6901 unescaping: ~1 → /, ~0 → ~ (order matters).
key, err := decodeJSONPointerSegment(raw)
if err != nil {
return nil, fmt.Errorf("json pointer %q: segment %q: %w", pointer, raw, err)
}
m, ok := current.(map[string]interface{})
if !ok {
traversed := "/" + strings.Join(segments[:i], "/")
return nil, fmt.Errorf("json pointer %q: value at %q is %T, not an object",
pointer, traversed, current)
}
val, exists := m[key]
if !exists {
return nil, fmt.Errorf("json pointer %q: key %q not found", pointer, key)
}
current = val
}
return current, nil
}
func decodeJSONPointerSegment(raw string) (string, error) {
var out strings.Builder
for i := 0; i < len(raw); i++ {
if raw[i] != '~' {
out.WriteByte(raw[i])
continue
}
if i+1 >= len(raw) {
return "", fmt.Errorf("invalid escape: ~ must be followed by 0 or 1")
}
switch raw[i+1] {
case '0':
out.WriteByte('~')
case '1':
out.WriteByte('/')
default:
return "", fmt.Errorf("invalid escape: ~%c must be ~0 or ~1", raw[i+1])
}
i++
}
return out.String(), nil
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"testing"
)
func TestReadJSONPointer_EmptyPointer(t *testing.T) {
data := map[string]interface{}{"key": "value"}
got, err := ReadJSONPointer(data, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["key"] != "value" {
t.Errorf("got %v, want map with key=value", m)
}
}
func TestReadJSONPointer_OneLevel(t *testing.T) {
data := map[string]interface{}{"key": "hello"}
got, err := ReadJSONPointer(data, "/key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "hello" {
t.Errorf("got %v, want %q", got, "hello")
}
}
func TestReadJSONPointer_TwoLevels(t *testing.T) {
data := map[string]interface{}{
"key": map[string]interface{}{
"subkey": "deep_value",
},
}
got, err := ReadJSONPointer(data, "/key/subkey")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "deep_value" {
t.Errorf("got %v, want %q", got, "deep_value")
}
}
func TestReadJSONPointer_MissingKey(t *testing.T) {
data := map[string]interface{}{"key": "value"}
_, err := ReadJSONPointer(data, "/nonexistent")
if err == nil {
t.Fatal("expected error for missing key, got nil")
}
want := `json pointer "/nonexistent": key "nonexistent" not found`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestReadJSONPointer_NonMapIntermediate(t *testing.T) {
data := map[string]interface{}{"key": "scalar_string"}
_, err := ReadJSONPointer(data, "/key/subkey")
if err == nil {
t.Fatal("expected error for non-map intermediate, got nil")
}
want := `json pointer "/key/subkey": value at "/key" is string, not an object`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestReadJSONPointer_RFC6901_Escaping(t *testing.T) {
// ~1 decodes to / and ~0 decodes to ~
data := map[string]interface{}{
"a/b": "slash_value",
"c~d": "tilde_value",
}
// ~1 -> /
got, err := ReadJSONPointer(data, "/a~1b")
if err != nil {
t.Fatalf("unexpected error for ~1 escape: %v", err)
}
if got != "slash_value" {
t.Errorf("got %v, want %q", got, "slash_value")
}
// ~0 -> ~
got, err = ReadJSONPointer(data, "/c~0d")
if err != nil {
t.Fatalf("unexpected error for ~0 escape: %v", err)
}
if got != "tilde_value" {
t.Errorf("got %v, want %q", got, "tilde_value")
}
}
func TestReadJSONPointer_InvalidEscape(t *testing.T) {
data := map[string]interface{}{
"a~2b": "literal",
"a~": "literal",
}
tests := []struct {
name string
pointer string
want string
}{
{
name: "unsupported escape code",
pointer: "/a~2b",
want: `json pointer "/a~2b": segment "a~2b": invalid escape: ~2 must be ~0 or ~1`,
},
{
name: "dangling tilde",
pointer: "/a~",
want: `json pointer "/a~": segment "a~": invalid escape: ~ must be followed by 0 or 1`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ReadJSONPointer(data, tt.pointer)
if err == nil {
t.Fatal("expected error for invalid escape, got nil")
}
if err.Error() != tt.want {
t.Errorf("error = %q, want %q", err.Error(), tt.want)
}
})
}
}
func TestReadJSONPointer_InvalidFormat(t *testing.T) {
data := map[string]interface{}{"key": "val"}
_, err := ReadJSONPointer(data, "no-leading-slash")
if err == nil {
t.Fatal("expected error for pointer without leading /")
}
want := `json pointer must start with '/' or be empty, got "no-leading-slash"`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// LarkChannelRoot captures ~/.lark-channel/config.json.
// Schema mirrors lark-channel-bridge/src/config/schema.ts:AppConfig.
// 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.
// Currently only `app` is defined; left as a struct (not a flat field) so
// future entries (oauth, alternate apps) can be added without re-shaping the
// top-level on disk.
type LarkChannelAccounts struct {
App LarkChannelApp `json:"app"`
}
// LarkChannelApp is the bot app credential entry.
//
// `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 SecretInput `json:"secret"`
Tenant string `json:"tenant"` // "feishu" | "lark"
}
// ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json.
func ReadLarkChannelConfig(path string) (*LarkChannelRoot, error) {
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err // caller formats user-facing message with path context
}
var root LarkChannelRoot
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
}
return &root, nil
}
+190
View File
@@ -0,0 +1,190 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestReadLarkChannelConfig_Valid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{"id":"cli_abc123","secret":"plain_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.ID; got != "cli_abc123" {
t.Errorf("ID = %q, want %q", got, "cli_abc123")
}
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")
}
}
func TestReadLarkChannelConfig_LarkTenant(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{"id":"cli_xyz","secret":"s","tenant":"lark"}}}`
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.Tenant; got != "lark" {
t.Errorf("Tenant = %q, want %q", got, "lark")
}
}
func TestReadLarkChannelConfig_MissingFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "does-not-exist.json")
_, err := ReadLarkChannelConfig(p)
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
if !os.IsNotExist(err) {
t.Errorf("expected os.IsNotExist, got %v", err)
}
}
func TestReadLarkChannelConfig_MalformedJSON(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
if err := os.WriteFile(p, []byte("{not valid json"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
_, err := ReadLarkChannelConfig(p)
if err == nil {
t.Fatal("expected error for malformed JSON, got nil")
}
}
func TestReadLarkChannelConfig_PartialFields(t *testing.T) {
// schema isComplete check belongs at the binder layer; the reader should
// happily parse a partial config — emptiness is detected downstream.
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{"accounts":{"app":{}}}`
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.ID != "" {
t.Errorf("expected empty ID, got %q", root.Accounts.App.ID)
}
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")
}
}
func TestReadLarkChannelConfig_UnknownFieldsIgnored(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
data := `{
"accounts": {
"app": {"id": "cli_a", "secret": "s", "tenant": "feishu"},
"oauth": {"clientId": "ignored"}
},
"preferences": {"theme": "dark"}
}`
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.ID; got != "cli_a" {
t.Errorf("ID = %q, want %q", got, "cli_a")
}
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// ReadOpenClawConfig reads and parses an openclaw.json file at the given path.
func ReadOpenClawConfig(path string) (*OpenClawRoot, error) {
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err // caller (bind.go) formats user-facing message with path context
}
var root OpenClawRoot
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
}
return &root, nil
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestReadOpenClawConfig_ValidSingleAccount(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "openclaw.json")
data := `{"channels":{"feishu":{"appId":"cli_abc","appSecret":"plain_secret","domain":"feishu"}}}`
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadOpenClawConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if root.Channels.Feishu == nil {
t.Fatal("expected Channels.Feishu to be non-nil")
}
if got := root.Channels.Feishu.AppID; got != "cli_abc" {
t.Errorf("AppID = %q, want %q", got, "cli_abc")
}
if got := root.Channels.Feishu.AppSecret.Plain; got != "plain_secret" {
t.Errorf("AppSecret.Plain = %q, want %q", got, "plain_secret")
}
if root.Channels.Feishu.AppSecret.Ref != nil {
t.Error("AppSecret.Ref should be nil for a plain string")
}
if got := root.Channels.Feishu.Brand; got != "feishu" {
t.Errorf("Brand = %q, want %q", got, "feishu")
}
}
func TestReadOpenClawConfig_ValidMultiAccount(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "openclaw.json")
data := `{
"channels": {
"feishu": {
"domain": "feishu",
"accounts": {
"work": {"appId": "cli_work", "appSecret": "secret_work", "domain": "feishu"},
"personal": {"appId": "cli_personal", "appSecret": "secret_personal", "domain": "lark"}
}
}
}
}`
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadOpenClawConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if root.Channels.Feishu == nil {
t.Fatal("expected Channels.Feishu to be non-nil")
}
apps := ListCandidateApps(root.Channels.Feishu)
if len(apps) != 2 {
t.Fatalf("ListCandidateApps returned %d apps, want 2", len(apps))
}
byLabel := make(map[string]CandidateApp, len(apps))
for _, a := range apps {
byLabel[a.Label] = a
}
work, ok := byLabel["work"]
if !ok {
t.Fatal("missing account label 'work'")
}
if work.AppID != "cli_work" {
t.Errorf("work.AppID = %q, want %q", work.AppID, "cli_work")
}
personal, ok := byLabel["personal"]
if !ok {
t.Fatal("missing account label 'personal'")
}
if personal.AppID != "cli_personal" {
t.Errorf("personal.AppID = %q, want %q", personal.AppID, "cli_personal")
}
}
func TestReadOpenClawConfig_MissingFeishu(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "openclaw.json")
data := `{"channels":{}}`
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadOpenClawConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if root.Channels.Feishu != nil {
t.Error("expected Channels.Feishu to be nil when not present in JSON")
}
}
func TestReadOpenClawConfig_InvalidJSON(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "openclaw.json")
if err := os.WriteFile(p, []byte(`{not valid json`), 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
_, err := ReadOpenClawConfig(p)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
func TestReadOpenClawConfig_FileNotFound(t *testing.T) {
_, err := ReadOpenClawConfig(filepath.Join(t.TempDir(), "nonexistent.json"))
if err == nil {
t.Fatal("expected error for non-existent file, got nil")
}
}
func TestReadOpenClawConfig_EnvTemplate(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "openclaw.json")
data := `{"channels":{"feishu":{"appId":"cli_env","appSecret":"${FEISHU_APP_SECRET}","domain":"feishu"}}}`
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadOpenClawConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
secret := root.Channels.Feishu.AppSecret
if secret.Plain != "${FEISHU_APP_SECRET}" {
t.Errorf("SecretInput.Plain = %q, want %q", secret.Plain, "${FEISHU_APP_SECRET}")
}
if secret.Ref != nil {
t.Error("SecretInput.Ref should be nil for env template string")
}
}
func TestReadOpenClawConfig_SecretRefObject(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "openclaw.json")
data := `{"channels":{"feishu":{"appId":"cli_ref","appSecret":{"source":"file","provider":"fp","id":"/path"},"domain":"feishu"}}}`
if err := os.WriteFile(p, []byte(data), 0o644); err != nil {
t.Fatalf("write temp file: %v", err)
}
root, err := ReadOpenClawConfig(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
secret := root.Channels.Feishu.AppSecret
if secret.Plain != "" {
t.Errorf("SecretInput.Plain = %q, want empty for object form", secret.Plain)
}
if secret.Ref == nil {
t.Fatal("SecretInput.Ref should be non-nil for object form")
}
if secret.Ref.Source != "file" {
t.Errorf("Ref.Source = %q, want %q", secret.Ref.Source, "file")
}
if secret.Ref.Provider != "fp" {
t.Errorf("Ref.Provider = %q, want %q", secret.Ref.Provider, "fp")
}
if secret.Ref.ID != "/path" {
t.Errorf("Ref.ID = %q, want %q", secret.Ref.ID, "/path")
}
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"fmt"
"os"
)
// ResolveSecretInput resolves a SecretInput to a plain-text secret string.
// This is the main dispatcher that handles all SecretInput forms:
// - Plain string passthrough
// - "${VAR_NAME}" env template expansion
// - SecretRef object routing to env/file/exec sub-resolvers
//
// The getenv parameter allows injection for testing (typically os.Getenv).
// This function is only called during config bind (cold path).
func ResolveSecretInput(input SecretInput, cfg *SecretsConfig, getenv func(string) string) (string, error) {
if getenv == nil {
getenv = os.Getenv
}
if input.IsZero() {
return "", fmt.Errorf("appSecret is missing or empty")
}
// Plain string form (includes env templates)
if input.IsPlain() {
return resolvePlainOrTemplate(input.Plain, getenv)
}
// SecretRef object form
return resolveSecretRef(input.Ref, cfg, getenv)
}
// resolvePlainOrTemplate handles plain strings and "${VAR}" templates.
func resolvePlainOrTemplate(value string, getenv func(string) string) (string, error) {
if value == "" {
return "", fmt.Errorf("appSecret is empty string")
}
// Check for env template pattern: "${VAR_NAME}"
matches := EnvTemplateRe.FindStringSubmatch(value)
if matches != nil {
varName := matches[1]
envValue := getenv(varName)
if envValue == "" {
return "", fmt.Errorf("env variable %q referenced in openclaw.json is not set or empty", varName)
}
return envValue, nil
}
// Plain string: use as-is
return value, nil
}
// resolveSecretRef dispatches a SecretRef to the appropriate sub-resolver.
func resolveSecretRef(ref *SecretRef, cfg *SecretsConfig, getenv func(string) string) (string, error) {
// Lookup provider configuration
providerConfig, err := LookupProvider(ref, cfg)
if err != nil {
return "", err
}
// Resolve the effective provider name once so downstream resolvers
// (notably the exec JSON payload) see the config-defaulted value instead
// of the unset literal on ref.Provider.
providerName := ResolveDefaultProvider(ref, cfg)
switch ref.Source {
case "env":
return resolveEnvRef(ref, providerConfig, getenv)
case "file":
return resolveFileRef(ref, providerConfig)
case "exec":
return resolveExecRef(ref, providerName, providerConfig, getenv)
default:
return "", fmt.Errorf("unsupported secret source %q", ref.Source)
}
}
// resolveEnvRef handles {source:"env"} SecretRef.
func resolveEnvRef(ref *SecretRef, pc *ProviderConfig, getenv func(string) string) (string, error) {
// Check allowlist if configured
if len(pc.Allowlist) > 0 {
allowed := false
for _, name := range pc.Allowlist {
if name == ref.ID {
allowed = true
break
}
}
if !allowed {
return "", fmt.Errorf("environment variable %q is not allowlisted in provider", ref.ID)
}
}
value := getenv(ref.ID)
if value == "" {
return "", fmt.Errorf("environment variable %q is missing or empty", ref.ID)
}
return value, nil
}
+241
View File
@@ -0,0 +1,241 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"time"
)
// execRequest is the JSON payload sent to exec provider's stdin.
type execRequest struct {
ProtocolVersion int `json:"protocolVersion"`
Provider string `json:"provider"`
IDs []string `json:"ids"`
}
// execResponse is the JSON payload expected from exec provider's stdout.
type execResponse struct {
ProtocolVersion int `json:"protocolVersion"`
Values map[string]interface{} `json:"values"`
Errors map[string]execRefError `json:"errors,omitempty"`
}
// execRefError is an optional per-id error in exec provider response.
type execRefError struct {
Message string `json:"message"`
}
// execRun bundles everything runExecCommand needs to spawn the child process.
// It is populated once by prepareExecRun and consumed exactly once by
// runExecCommand; keeping the two stages pure data + pure side effect makes
// each independently testable.
type execRun struct {
Path string // absolute, already-audited path to the command
Args []string // command arguments (from pc.Args)
Env []string // minimal child env (passEnv + explicit env only)
Request []byte // JSON payload to feed on the child's stdin
Timeout time.Duration // spawn deadline
MaxOut int // hard cap on stdout size, enforced post-Run
}
// resolveExecRef handles {source:"exec"} SecretRef resolution. It audits the
// command path, runs the child under a timeout with a hard stdout cap, and
// extracts the secret from the JSON response. providerName is the caller-
// resolved effective alias (honours secrets.defaults.exec from openclaw.json).
func resolveExecRef(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (string, error) {
prep, err := prepareExecRun(ref, providerName, pc, getenv)
if err != nil {
return "", err
}
stdout, err := runExecCommand(prep)
if err != nil {
return "", err
}
return extractExecSecret(stdout, ref.ID, effectiveJSONOnly(pc))
}
// prepareExecRun audits the command path, marshals the JSON request,
// assembles the minimal child env, and resolves timeout / output limits.
// Never spawns a process — the returned execRun is pure data.
func prepareExecRun(ref *SecretRef, providerName string, pc *ProviderConfig, getenv func(string) string) (*execRun, error) {
if pc.Command == "" {
return nil, fmt.Errorf("exec provider command is empty")
}
securePath, err := AssertSecurePath(AuditParams{
TargetPath: pc.Command,
Label: "exec provider command",
TrustedDirs: pc.TrustedDirs,
AllowInsecurePath: pc.AllowInsecurePath,
AllowReadableByOthers: true, // exec commands are typically 755
AllowSymlinkPath: pc.AllowSymlinkCommand,
})
if err != nil {
return nil, fmt.Errorf("exec provider security audit failed: %w", err)
}
reqJSON, err := marshalExecRequest(ref, providerName)
if err != nil {
return nil, err
}
timeoutMs, maxOut := effectiveExecLimits(pc)
return &execRun{
Path: securePath,
Args: pc.Args,
Env: buildExecEnv(pc, getenv),
Request: reqJSON,
Timeout: time.Duration(timeoutMs) * time.Millisecond,
MaxOut: maxOut,
}, nil
}
// marshalExecRequest encodes the JSON protocol request sent to the child.
// providerName is supplied by resolveSecretRef after consulting
// secrets.defaults.exec; an empty value falls back to DefaultProviderAlias
// so the function can still be reasoned about in isolation.
func marshalExecRequest(ref *SecretRef, providerName string) ([]byte, error) {
if providerName == "" {
providerName = DefaultProviderAlias
}
data, err := json.Marshal(execRequest{
ProtocolVersion: 1,
Provider: providerName,
IDs: []string{ref.ID},
})
if err != nil {
return nil, fmt.Errorf("exec provider: failed to marshal request: %w", err)
}
return data, nil
}
// buildExecEnv assembles the child's environment: only variables listed in
// pc.PassEnv (and non-empty in the parent) plus pc.Env entries. The child
// never inherits the full parent env — always set cmd.Env explicitly.
func buildExecEnv(pc *ProviderConfig, getenv func(string) string) []string {
env := make([]string, 0, len(pc.PassEnv)+len(pc.Env))
for _, key := range pc.PassEnv {
if val := getenv(key); val != "" {
env = append(env, key+"="+val)
}
}
for key, val := range pc.Env {
env = append(env, key+"="+val)
}
return env
}
// effectiveExecLimits returns (timeoutMs, maxOutputBytes), falling back to
// package defaults for any non-positive value. The exec provider uses its
// own NoOutputTimeoutMs field (pc.TimeoutMs is the file-provider field and
// should not be consulted here); the value is applied as the overall
// deadline for the child process.
func effectiveExecLimits(pc *ProviderConfig) (timeoutMs, maxOutputBytes int) {
timeoutMs = pc.NoOutputTimeoutMs
if timeoutMs <= 0 {
timeoutMs = DefaultExecTimeoutMs
}
maxOutputBytes = pc.MaxOutputBytes
if maxOutputBytes <= 0 {
maxOutputBytes = DefaultExecMaxOutputBytes
}
return timeoutMs, maxOutputBytes
}
// effectiveJSONOnly returns pc.JSONOnly or its documented default (true).
func effectiveJSONOnly(pc *ProviderConfig) bool {
if pc.JSONOnly != nil {
return *pc.JSONOnly
}
return true
}
// runExecCommand spawns the child per prep, feeds prep.Request on stdin, and
// returns trimmed stdout on success. Failure modes:
// - timeout → typed error with the configured limit
// - non-zero exit → wrapped *exec.ExitError
// - stdout exceeds prep.MaxOut → typed error (size enforced post-Run)
// - empty trimmed stdout → typed error
func runExecCommand(prep *execRun) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), prep.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, prep.Path, prep.Args...)
cmd.Dir = filepath.Dir(prep.Path)
cmd.Env = prep.Env // always set — leaving nil would inherit the parent env
cmd.Stdin = bytes.NewReader(prep.Request)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("exec provider timed out after %dms", int(prep.Timeout/time.Millisecond))
}
return nil, fmt.Errorf("exec provider exited with error: %w", err)
}
if stdout.Len() > prep.MaxOut {
return nil, fmt.Errorf("exec provider output exceeded maxOutputBytes (%d)", prep.MaxOut)
}
trimmed := bytes.TrimSpace(stdout.Bytes())
if len(trimmed) == 0 {
return nil, fmt.Errorf("exec provider returned empty stdout")
}
return trimmed, nil
}
// extractExecSecret parses stdout as a JSON execResponse and returns the
// string value at refID. When jsonOnly is false and the response is not valid
// JSON (or the value is not a string), it falls back to the raw stdout or the
// JSON encoding of the value respectively — mirroring OpenClaw's resolve.ts.
func extractExecSecret(stdout []byte, refID string, jsonOnly bool) (string, error) {
var resp execResponse
if err := json.Unmarshal(stdout, &resp); err != nil {
if !jsonOnly {
return string(stdout), nil
}
return "", fmt.Errorf("exec provider returned invalid JSON: %w", err)
}
if resp.ProtocolVersion != 1 {
return "", fmt.Errorf("exec provider protocolVersion must be 1, got %d", resp.ProtocolVersion)
}
if refErr, ok := resp.Errors[refID]; ok {
msg := refErr.Message
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("exec provider failed for id %q: %s", refID, msg)
}
if resp.Values == nil {
return "", fmt.Errorf("exec provider response missing 'values'")
}
value, ok := resp.Values[refID]
if !ok {
return "", fmt.Errorf("exec provider response missing id %q", refID)
}
if str, ok := value.(string); ok {
return str, nil
}
if !jsonOnly {
data, err := json.Marshal(value)
if err != nil {
return "", fmt.Errorf("exec provider value for id %q is not JSON-serializable: %w", refID, err)
}
return string(data), nil
}
return "", fmt.Errorf("exec provider value for id %q is not a string", refID)
}
@@ -0,0 +1,437 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
)
// writeExecHelper writes a small shell script that mimics an exec provider.
// The script reads stdin (the JSON request) and writes a JSON response to stdout.
func writeExecHelper(t *testing.T, dir, body string) string {
t.Helper()
p := filepath.Join(dir, "helper.sh")
script := "#!/bin/sh\n" + body
if err := os.WriteFile(p, []byte(script), 0o700); err != nil {
t.Fatalf("write helper script: %v", err)
}
return p
}
func TestResolveExecRef_EmptyCommand(t *testing.T) {
ref := &SecretRef{Source: "exec", ID: "MY_KEY"}
pc := &ProviderConfig{Source: "exec", Command: ""}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for empty command, got nil")
}
want := "exec provider command is empty"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveExecRef_CommandNotFound(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("path audit not applicable on Windows")
}
ref := &SecretRef{Source: "exec", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: "/nonexistent/command",
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for nonexistent command, got nil")
}
}
func TestResolveExecRef_JSONResponse(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
// Script reads stdin (ignores), writes valid JSON response
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1,"values":{"MY_KEY":"exec_secret_123"}}'
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
got, err := resolveExecRef(ref, "", pc, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "exec_secret_123" {
t.Errorf("got %q, want %q", got, "exec_secret_123")
}
}
func TestResolveExecRef_PerRefError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1,"values":{},"errors":{"MY_KEY":{"message":"secret not found"}}}'
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for per-ref error, got nil")
}
want := `exec provider failed for id "MY_KEY": secret not found`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveExecRef_WrongProtocolVersion(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":99,"values":{"MY_KEY":"v"}}'
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for wrong protocol version, got nil")
}
want := "exec provider protocolVersion must be 1, got 99"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveExecRef_MissingValues(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1}'
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for missing values, got nil")
}
want := "exec provider response missing 'values'"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveExecRef_MissingID(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1,"values":{"OTHER":"val"}}'
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for missing ID, got nil")
}
want := `exec provider response missing id "MY_KEY"`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveExecRef_EmptyStdout(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for empty stdout, got nil")
}
want := "exec provider returned empty stdout"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveExecRef_InvalidJSON_JSONOnly(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
echo "not json"
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
// JSONOnly defaults to true (nil)
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
func TestResolveExecRef_NonJSON_RawString(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
echo "raw_secret_value"
`)
jsonOnly := false
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
JSONOnly: &jsonOnly,
}
got, err := resolveExecRef(ref, "", pc, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "raw_secret_value" {
t.Errorf("got %q, want %q", got, "raw_secret_value")
}
}
func TestResolveExecRef_NonStringValue_JSONOnly(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1,"values":{"MY_KEY":42}}'
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for non-string value with jsonOnly=true, got nil")
}
want := `exec provider value for id "MY_KEY" is not a string`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveExecRef_NonStringValue_NoJSONOnly(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1,"values":{"MY_KEY":42}}'
`)
jsonOnly := false
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
JSONOnly: &jsonOnly,
}
got, err := resolveExecRef(ref, "", pc, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "42" {
t.Errorf("got %q, want %q", got, "42")
}
}
func TestResolveExecRef_CommandExitError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `exit 1
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for command exit error, got nil")
}
}
func TestResolveExecRef_PassEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
// Script uses TEST_SECRET env to produce value
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$TEST_SECRET"
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
PassEnv: []string{"TEST_SECRET"},
}
getenv := func(key string) string {
if key == "TEST_SECRET" {
return "passed_env_value"
}
return ""
}
got, err := resolveExecRef(ref, "", pc, getenv)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "passed_env_value" {
t.Errorf("got %q, want %q", got, "passed_env_value")
}
}
func TestResolveExecRef_ExplicitEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
helper := writeExecHelper(t, dir, `cat > /dev/null
printf '{"protocolVersion":1,"values":{"MY_KEY":"%s"}}' "$CUSTOM_VAR"
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
Env: map[string]string{"CUSTOM_VAR": "explicit_value"},
}
got, err := resolveExecRef(ref, "", pc, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "explicit_value" {
t.Errorf("got %q, want %q", got, "explicit_value")
}
}
func TestResolveExecRef_OutputExceedsMax(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell scripts not applicable on Windows")
}
dir := t.TempDir()
// Script outputs more than maxOutputBytes
helper := writeExecHelper(t, dir, `cat > /dev/null
python3 -c "print('x' * 200)"
`)
ref := &SecretRef{Source: "exec", Provider: "default", ID: "MY_KEY"}
pc := &ProviderConfig{
Source: "exec",
Command: helper,
AllowInsecurePath: true,
MaxOutputBytes: 10,
}
_, err := resolveExecRef(ref, "", pc, nil)
if err == nil {
t.Fatal("expected error for output exceeding maxOutputBytes, got nil")
}
want := fmt.Sprintf("exec provider output exceeded maxOutputBytes (%d)", 10)
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
// SingleValueFileRefID is the required ref.ID for singleValue file mode
// (aligned with OpenClaw ref-contract.ts SINGLE_VALUE_FILE_REF_ID).
const SingleValueFileRefID = "$SINGLE_VALUE"
// resolveFileRef handles {source:"file"} SecretRef resolution.
// Reads the file via assertSecurePath audit, then extracts the secret value
// based on the provider's mode (singleValue or json with JSON Pointer).
func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) {
if pc.Path == "" {
return "", fmt.Errorf("file provider path is empty")
}
// OpenClaw preserves user-authored `~/...` paths verbatim on disk for
// portability and resolves them at read time. lark-cli reads the file
// raw, so we mirror that resolution here before the audit — otherwise
// an unambiguous home-relative path would be rejected by
// requireAbsolutePath, which is meant to guard against cwd-relative
// paths (a different concern). expandTildePath honours OPENCLAW_HOME so
// a tilde inside an OPENCLAW_HOME-overridden config resolves to the
// same absolute path OpenClaw itself would have used.
targetPath := expandTildePath(pc.Path)
// Security audit on file path
securePath, err := AssertSecurePath(AuditParams{
TargetPath: targetPath,
Label: "secrets.providers file path",
TrustedDirs: pc.TrustedDirs,
AllowInsecurePath: pc.AllowInsecurePath,
AllowReadableByOthers: false, // file provider: strict by default
AllowSymlinkPath: false,
})
if err != nil {
return "", fmt.Errorf("file provider security audit failed: %w", err)
}
// Read file content
maxBytes := pc.MaxBytes
if maxBytes <= 0 {
maxBytes = DefaultFileMaxBytes
}
// Note: vfs.ReadFile loads the entire file. maxBytes is enforced post-read
// because vfs does not expose a size-limited reader. For secret files this
// is acceptable (default limit 1 MiB; secrets are typically < 1 KB).
data, err := vfs.ReadFile(securePath)
if err != nil {
return "", fmt.Errorf("failed to read secret file %s: %w", securePath, err)
}
if len(data) > maxBytes {
return "", fmt.Errorf("file provider exceeded maxBytes (%d)", maxBytes)
}
content := string(data)
mode := pc.Mode
if mode == "" {
mode = "json" // default mode per OpenClaw
}
switch mode {
case "singleValue":
// OpenClaw requires ref.id == SINGLE_VALUE_FILE_REF_ID for singleValue mode
if ref.ID != SingleValueFileRefID {
return "", fmt.Errorf("singleValue file provider expects ref id %q, got %q",
SingleValueFileRefID, ref.ID)
}
// Entire file content is the secret; trim trailing newline
return strings.TrimRight(content, "\r\n"), nil
case "json":
// Parse as JSON, then navigate via JSON Pointer (ref.ID)
var parsed interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
return "", fmt.Errorf("file provider JSON parse error: %w", err)
}
value, err := ReadJSONPointer(parsed, ref.ID)
if err != nil {
return "", fmt.Errorf("file provider JSON Pointer %q: %w", ref.ID, err)
}
// Value must be a string
strValue, ok := value.(string)
if !ok {
return "", fmt.Errorf("file provider JSON Pointer %q resolved to non-string value", ref.ID)
}
return strValue, nil
default:
return "", fmt.Errorf("unsupported file provider mode %q", mode)
}
}
@@ -0,0 +1,318 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveFileRef_SingleValue(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: p,
Mode: "singleValue",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "my_secret" {
t.Errorf("got %q, want %q", got, "my_secret")
}
}
func TestResolveFileRef_SingleValue_WrongRefID(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(p, []byte("my_secret\n"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: "WRONG_ID"}
pc := &ProviderConfig{
Source: "file",
Path: p,
Mode: "singleValue",
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for wrong ref ID, got nil")
}
want := `singleValue file provider expects ref id "$SINGLE_VALUE", got "WRONG_ID"`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveFileRef_JSONMode(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets.json")
content := `{"providers":{"feishu":{"key":"secret123"}}}`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
pc := &ProviderConfig{
Source: "file",
Path: p,
Mode: "json",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "secret123" {
t.Errorf("got %q, want %q", got, "secret123")
}
}
func TestResolveFileRef_JSONMode_MissingPointer(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets.json")
content := `{"providers":{"feishu":{"key":"secret123"}}}`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/providers/nonexistent/key"}
pc := &ProviderConfig{
Source: "file",
Path: p,
Mode: "json",
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for missing JSON pointer, got nil")
}
want := `file provider JSON Pointer "/providers/nonexistent/key": json pointer "/providers/nonexistent/key": key "nonexistent" not found`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveFileRef_FileNotFound(t *testing.T) {
nonexistent := filepath.Join(t.TempDir(), "no_such_file.txt")
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: nonexistent,
Mode: "singleValue",
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestResolveFileRef_EmptyProviderPath(t *testing.T) {
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{Source: "file", Path: "", Mode: "singleValue", AllowInsecurePath: true}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for empty provider path, got nil")
}
want := "file provider path is empty"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveFileRef_JSONMode_NonStringValue(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets.json")
if err := os.WriteFile(p, []byte(`{"count":42}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/count"}
pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for non-string JSON value, got nil")
}
want := `file provider JSON Pointer "/count" resolved to non-string value`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveFileRef_UnsupportedMode(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(p, []byte("data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{Source: "file", Path: p, Mode: "yaml", AllowInsecurePath: true}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for unsupported mode, got nil")
}
want := `unsupported file provider mode "yaml"`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveFileRef_DefaultMode_IsJSON(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets.json")
if err := os.WriteFile(p, []byte(`{"key":"value123"}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/key"}
pc := &ProviderConfig{Source: "file", Path: p, Mode: "", AllowInsecurePath: true}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "value123" {
t.Errorf("got %q, want %q", got, "value123")
}
}
func TestResolveFileRef_JSONMode_InvalidJSON(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bad.json")
if err := os.WriteFile(p, []byte("not json"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/key"}
pc := &ProviderConfig{Source: "file", Path: p, Mode: "json", AllowInsecurePath: true}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "big.txt")
if err := os.WriteFile(p, []byte("this content is longer than 5 bytes"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: p,
Mode: "singleValue",
MaxBytes: 5,
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for file exceeding maxBytes, got nil")
}
want := "file provider exceeded maxBytes (5)"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
// TestResolveFileRef_TildePath_SingleValue is the end-to-end smoke test
// for the fix: a singleValue file provider with a ~/-relative path
// resolves correctly through resolveFileRef. Before this PR the audit
// would reject the path as "must be absolute".
func TestResolveFileRef_TildePath_SingleValue(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(p, []byte("tilde_secret\n"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "~/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "tilde_secret" {
t.Errorf("got %q, want %q", got, "tilde_secret")
}
}
// TestResolveFileRef_RelativePath_StillRejected guards the absolute-path
// audit: cwd-relative input must still be rejected even though tilde was
// loosened. Catches regressions if expandTildePath is ever widened to
// also expand "./..." (which would weaken the audit's invariant).
func TestResolveFileRef_RelativePath_StillRejected(t *testing.T) {
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "relative/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for relative path, got nil")
}
wantSub := "path must be absolute"
if !strings.Contains(err.Error(), wantSub) {
t.Errorf("error = %q, want substring %q", err.Error(), wantSub)
}
}
// TestResolveFileRef_TildePath_JSONMode verifies the tilde-expansion
// path works for json mode (where ref id is a JSON pointer) as well as
// singleValue mode — the mechanism is mode-agnostic.
func TestResolveFileRef_TildePath_JSONMode(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secrets.json")
content := `{"providers":{"feishu":{"key":"json_via_tilde"}}}`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
pc := &ProviderConfig{
Source: "file",
Path: "~/secrets.json",
Mode: "json",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "json_via_tilde" {
t.Errorf("got %q, want %q", got, "json_via_tilde")
}
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"testing"
)
func makeGetenv(m map[string]string) func(string) string {
return func(key string) string { return m[key] }
}
func TestResolve_PlainString(t *testing.T) {
got, err := ResolveSecretInput(SecretInput{Plain: "my_secret"}, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "my_secret" {
t.Errorf("got %q, want %q", got, "my_secret")
}
}
func TestResolve_EmptyInput(t *testing.T) {
_, err := ResolveSecretInput(SecretInput{}, nil, nil)
if err == nil {
t.Fatal("expected error for empty input, got nil")
}
want := "appSecret is missing or empty"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolve_EnvTemplate_Found(t *testing.T) {
getenv := makeGetenv(map[string]string{"MY_VAR": "resolved_value"})
got, err := ResolveSecretInput(SecretInput{Plain: "${MY_VAR}"}, nil, getenv)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "resolved_value" {
t.Errorf("got %q, want %q", got, "resolved_value")
}
}
func TestResolve_EnvTemplate_NotFound(t *testing.T) {
getenv := makeGetenv(map[string]string{})
_, err := ResolveSecretInput(SecretInput{Plain: "${MY_VAR}"}, nil, getenv)
if err == nil {
t.Fatal("expected error for unset env variable, got nil")
}
want := `env variable "MY_VAR" referenced in openclaw.json is not set or empty`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolve_EnvTemplate_InvalidFormat(t *testing.T) {
getenv := makeGetenv(map[string]string{})
got, err := ResolveSecretInput(SecretInput{Plain: "${lowercase}"}, nil, getenv)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "${lowercase}" {
t.Errorf("got %q, want %q (treated as plain string)", got, "${lowercase}")
}
}
func TestResolve_EnvRef(t *testing.T) {
getenv := makeGetenv(map[string]string{"MY_KEY": "env_val"})
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
got, err := ResolveSecretInput(input, nil, getenv)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "env_val" {
t.Errorf("got %q, want %q", got, "env_val")
}
}
func TestResolve_EnvRef_NotFound(t *testing.T) {
getenv := makeGetenv(map[string]string{})
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
_, err := ResolveSecretInput(input, nil, getenv)
if err == nil {
t.Fatal("expected error for missing env variable, got nil")
}
}
func TestResolve_EnvRef_Allowlisted(t *testing.T) {
getenv := makeGetenv(map[string]string{"MY_KEY": "allowed_val"})
cfg := &SecretsConfig{
Providers: map[string]*ProviderConfig{
"default": {Source: "env", Allowlist: []string{"MY_KEY"}},
},
}
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
got, err := ResolveSecretInput(input, cfg, getenv)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "allowed_val" {
t.Errorf("got %q, want %q", got, "allowed_val")
}
}
func TestResolve_EnvRef_NotAllowlisted(t *testing.T) {
getenv := makeGetenv(map[string]string{"MY_KEY": "some_val"})
cfg := &SecretsConfig{
Providers: map[string]*ProviderConfig{
"default": {Source: "env", Allowlist: []string{"OTHER"}},
},
}
input := SecretInput{Ref: &SecretRef{Source: "env", Provider: "default", ID: "MY_KEY"}}
_, err := ResolveSecretInput(input, cfg, getenv)
if err == nil {
t.Fatal("expected error for non-allowlisted key, got nil")
}
want := `environment variable "MY_KEY" is not allowlisted in provider`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolve_UnknownSource(t *testing.T) {
getenv := makeGetenv(map[string]string{})
cfg := &SecretsConfig{
Providers: map[string]*ProviderConfig{
"default": {Source: "unknown"},
},
}
input := SecretInput{Ref: &SecretRef{Source: "unknown", Provider: "default", ID: "some_id"}}
_, err := ResolveSecretInput(input, cfg, getenv)
if err == nil {
t.Fatal("expected error for unknown source, got nil")
}
}
func TestResolve_ProviderNotConfigured(t *testing.T) {
getenv := makeGetenv(map[string]string{})
cfg := &SecretsConfig{
Providers: map[string]*ProviderConfig{},
}
input := SecretInput{Ref: &SecretRef{Source: "file", Provider: "nonexistent", ID: "/some/path"}}
_, err := ResolveSecretInput(input, cfg, getenv)
if err == nil {
t.Fatal("expected error for non-configured provider, got nil")
}
want := `secret provider "nonexistent" is not configured (ref: file:nonexistent:/some/path)`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
+180
View File
@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
// hasTildePrefix reports whether s begins with `~` followed by end-of-string,
// `/`, or `\` — the form OpenClaw treats as home-relative.
func hasTildePrefix(s string) bool {
if s == "" || s[0] != '~' {
return false
}
if len(s) == 1 {
return true
}
return s[1] == '/' || s[1] == '\\'
}
// joinTildeSuffix expands a tilde-prefixed string against a resolved home
// directory. Replaces only the leading `~` so the original separator
// (forward or back slash) and suffix bytes are kept verbatim, matching
// OpenClaw's `input.replace(/^~(?=$|[\\/])/, home)` semantics rather than
// going through filepath.Join (which would silently drop a literal `\` on
// POSIX). filepath.Clean is applied so `..` and duplicate separators are
// collapsed in the same way Node's path.resolve does on each platform.
//
// Caller must ensure hasTildePrefix(s) is true and home is non-empty.
func joinTildeSuffix(s, home string) string {
if len(s) == 1 {
return home
}
return filepath.Clean(home + s[1:])
}
// normalizeSentinel applies OpenClaw's normalize() helper to a single
// string: trims whitespace and treats the JS-flavoured literals
// "undefined" / "null" (along with empty/whitespace-only) as unset.
func normalizeSentinel(v string) string {
v = strings.TrimSpace(v)
if v == "undefined" || v == "null" {
return ""
}
return v
}
// osHome returns the OS-level home directory by walking OpenClaw's
// resolution chain: HOME → USERPROFILE → OS user database (getpwuid on
// Unix / user32 on Windows, via os/user.Current). Each candidate is
// passed through normalizeSentinel so sentinel literals and blank
// strings fall through.
//
// Matches OpenClaw's resolveRawOsHomeDir env chain so the same tilde
// resolves against the same home under mixed shell environments and
// accidentally-stringified env values. Go's stdlib os.UserHomeDir on
// Unix only re-reads HOME and gives up; Node's os.homedir() still
// returns the account home via the user database, so the explicit
// user.Current() step is what keeps OpenClaw-authored `~/...` working
// in HOME-unset shells.
//
// Deliberate hybrid contract — neither a strict mirror of OpenClaw
// nor a strict reject-on-missing:
//
// - OpenClaw's final fallback is cwd (via resolveRequiredHomeDir →
// process.cwd()). We don't do that because requireAbsolutePath
// exists precisely to reject cwd-dependent paths; routing
// `~/secret` through cwd would defeat the audit invariant.
//
// - We still go through user.Current() before giving up, even when
// HOME is a sentinel literal ("undefined" / "null") and
// USERPROFILE is unset. At that point OpenClaw would land on cwd,
// and a strict implementation would reject; user.Current() lands
// on the account home instead — cwd-independent and user-bound,
// so it satisfies the audit's safety goal while still letting
// ~/-authored configs resolve in a malformed-env shell.
//
// - Only returns "" when the env chain AND user.Current() are all
// unresolvable, at which point the caller surfaces a clean
// "path must be absolute" error from the audit.
func osHome() string {
if v := normalizeSentinel(os.Getenv("HOME")); v != "" {
return v
}
if v := normalizeSentinel(os.Getenv("USERPROFILE")); v != "" {
return v
}
if u, err := user.Current(); err == nil {
return normalizeSentinel(u.HomeDir)
}
return ""
}
// explicitOpenClawHome reads OPENCLAW_HOME with OpenClaw's normalize()
// semantics applied.
func explicitOpenClawHome() string {
return normalizeSentinel(os.Getenv("OPENCLAW_HOME"))
}
// absolutize returns p as an absolute path, resolving against the process
// cwd when p is relative. Returns "" when the cwd cannot be resolved.
// Wraps filepath.Abs semantics via vfs.Getwd because forbidigo bans
// filepath.Abs inside internal/ packages.
func absolutize(p string) string {
if p == "" {
return ""
}
if filepath.IsAbs(p) {
return filepath.Clean(p)
}
wd, err := vfs.Getwd()
if err != nil {
return ""
}
return filepath.Join(wd, p)
}
// openClawHome returns the home directory used to resolve `~`-relative paths
// authored against OpenClaw's config. Closely mirrors OpenClaw's
// home-resolution semantics so the same tilde resolves to the same
// absolute path here as inside OpenClaw runtime under all normal
// conditions.
//
// Resolution order:
// 1. OPENCLAW_HOME env var, when set (sentinel-normalised).
// 2. If OPENCLAW_HOME itself has a tilde prefix, expand it against the OS
// home (see osHome); the result is empty when the OS home is
// unresolvable.
// 3. Otherwise fall back to the OS home.
//
// The returned path is absolute (relative OPENCLAW_HOME values are
// absolutised against the process cwd, matching Node path.resolve in
// OpenClaw's pipeline).
//
// Returns "" when no home can be resolved. This is a deliberate
// divergence from OpenClaw, whose read pipeline would fall back to
// cwd via resolveRequiredHomeDir — see osHome for the rationale.
func openClawHome() string {
raw := explicitOpenClawHome()
switch {
case raw == "":
raw = osHome()
case hasTildePrefix(raw):
h := osHome()
if h == "" {
return ""
}
raw = joinTildeSuffix(raw, h)
}
return absolutize(raw)
}
// expandTildePath resolves a leading `~` or `~/...` prefix to OpenClaw's
// effective home directory (see openClawHome).
//
// Returns the input unchanged when it lacks a tilde prefix or when
// openClawHome cannot resolve a home directory. The latter case is a
// deliberate divergence from OpenClaw, whose read pipeline falls back
// to cwd — see osHome. Surfacing a "path must be absolute" error from
// the audit is preferable to silently routing a user-authored
// `~/secret` through cwd resolution.
//
// `~user` shell-style expansion is intentionally not supported (OpenClaw
// does not support it either).
func expandTildePath(p string) string {
if !hasTildePrefix(p) {
return p
}
home := openClawHome()
if home == "" {
return p
}
return joinTildeSuffix(p, home)
}
+293
View File
@@ -0,0 +1,293 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setFakeOSHome controls osHome's env-chain inputs (HOME and USERPROFILE)
// in one call so tests stay deterministic across platforms. osHome reads
// HOME first, then USERPROFILE, then user.Current(); setting only one of
// the two leaves the test sensitive to whichever the runner happens to
// have populated. Passing dir == "" disables both env entries so tests
// can exercise the user.Current() fallback or no-home edge cases.
func setFakeOSHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
}
// isolateRuntimeWrites parks the process cwd in a fresh TempDir for the
// test's duration. Tests that set HOME to a sentinel literal trigger Go
// runtime side effects — most visibly the telemetry subsystem, which
// calls os.UserConfigDir() (= "$HOME/Library/Application Support" on
// darwin) and happily writes through a relative result like
// "undefined/Library/...". Without isolation those files land in the
// package or repo dir and get accidentally staged. Chdir'ing into a
// TempDir routes the noise into a path testing.T auto-cleans.
func isolateRuntimeWrites(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(orig)
})
}
// TestOpenClawHome covers the openClawHome resolution table: empty /
// sentinel OPENCLAW_HOME falls back to the OS home, explicit absolute
// values are used verbatim (with whitespace trimmed), and tilde-prefixed
// values recurse through the OS home.
func TestOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
explicit := t.TempDir()
setFakeOSHome(t, homeDir)
tests := []struct {
name string
openclawEnv string
want string
}{
{"unset falls back to OS home", "", homeDir},
{"undefined literal treated as unset", "undefined", homeDir},
{"null literal treated as unset", "null", homeDir},
{"whitespace-only treated as unset", " ", homeDir},
{"explicit absolute path used verbatim", explicit, explicit},
{"explicit absolute path is trimmed", " " + explicit + " ", explicit},
{"bare tilde resolves to OS home", "~", homeDir},
{"tilde-prefixed value recurses through OS home", "~/custom", filepath.Join(homeDir, "custom")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("OPENCLAW_HOME", tc.openclawEnv)
got := openClawHome()
if got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_RelativeIsAbsolutized confirms a relative
// OPENCLAW_HOME is resolved against the process cwd, mirroring Node's
// path.resolve behaviour in OpenClaw.
func TestOpenClawHome_RelativeIsAbsolutized(t *testing.T) {
t.Setenv("OPENCLAW_HOME", filepath.FromSlash("relative/dir"))
got := openClawHome()
if !filepath.IsAbs(got) {
t.Fatalf("openClawHome() = %q, want absolute path", got)
}
wantSuffix := filepath.FromSlash("relative/dir")
if !strings.HasSuffix(got, wantSuffix) {
t.Errorf("openClawHome() = %q, want suffix %q", got, wantSuffix)
}
}
// TestOpenClawHome_FallsBackToUserDatabase pins osHome's final fallback
// to the OS user database when HOME and USERPROFILE are both unset,
// matching Node's os.homedir() (which uses getpwuid). Cwd-independent
// and user-bound, so it does not conflict with the "no cwd fallback"
// rule documented on osHome.
func TestOpenClawHome_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home from user.Current)", got, u.HomeDir)
}
}
// TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback pins that
// a tilde-form OPENCLAW_HOME ("~/custom") expands against the
// user-database fallback when HOME and USERPROFILE are both unset.
// Without the user.Current() step in osHome this would have failed
// (returning "") and dropped the bind back to the audit's
// "path must be absolute" error.
func TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "~/custom")
got := openClawHome()
want := filepath.Join(u.HomeDir, "custom")
if got != want {
t.Errorf("openClawHome() = %q, want %q", got, want)
}
}
// TestExpandTildePath covers the full input grid for expandTildePath:
// bare tilde, tilde-slash, tilde + suffix, nested suffix, plain absolute
// and relative literals, and the intentionally-unchanged forms (~user,
// ~foo) that OpenClaw does not expand either.
func TestExpandTildePath(t *testing.T) {
fakeHome := t.TempDir()
absFixture := filepath.Join(fakeHome, "abs.json")
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"bare tilde", "~", fakeHome},
{"tilde slash", "~/", fakeHome},
{"tilde with file", "~/secret.json", filepath.Join(fakeHome, "secret.json")},
{"tilde with nested path", "~/.openclaw/secret.json", filepath.Join(fakeHome, ".openclaw/secret.json")},
{"absolute unchanged", absFixture, absFixture},
{"relative unchanged", "foo/bar", "foo/bar"},
{"dot relative unchanged", "../foo", "../foo"},
{"tilde user form unchanged", "~root/foo", "~root/foo"},
{"tilde without separator unchanged", "~foo", "~foo"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := expandTildePath(tc.in)
if got != tc.want {
t.Errorf("expandTildePath(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestExpandTildePath_RespectsOpenClawHome verifies that with
// OPENCLAW_HOME set, tilde expansion uses that custom home rather than
// the OS home — the integration-level invariant that closes the
// internal inconsistency CodeX's first review flagged.
func TestExpandTildePath_RespectsOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
clawHome := t.TempDir()
setFakeOSHome(t, homeDir)
t.Setenv("OPENCLAW_HOME", clawHome)
got := expandTildePath("~/secret.json")
want := filepath.Join(clawHome, "secret.json")
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (should use OPENCLAW_HOME)", "~/secret.json", got, want)
}
if got == filepath.Join(homeDir, "secret.json") {
t.Errorf("expandTildePath unexpectedly used OS home %q instead of OPENCLAW_HOME %q", homeDir, clawHome)
}
}
// TestExpandTildePath_FallsBackToUserDatabase is the end-to-end
// equivalent of TestOpenClawHome_FallsBackToUserDatabase: with HOME and
// USERPROFILE both unset, expandTildePath still resolves `~/foo` via
// osHome's user.Current() step. Matches Node os.homedir() and keeps
// OpenClaw-authored configs working in minimal-env shells.
func TestExpandTildePath_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath("~/foo")
want := filepath.Join(u.HomeDir, "foo")
if got != want {
t.Errorf("expandTildePath(~/foo) = %q, want %q", got, want)
}
}
// TestOpenClawHome_OSHomeNormalization pins OpenClaw's sentinel
// normalisation on the env chain: the literals "undefined" / "null" /
// blank-or-whitespace are all treated as unset, so a JS-flavoured
// accidentally-stringified env value (e.g. `HOME=undefined` from a
// shell wrapper) doesn't end up as a literal directory component when
// the user authored `~/secret`. Combined with the user.Current()
// fallback further down (see TestOpenClawHome_FallsBackToUserDatabase),
// the contract is: a malformed HOME falls through to USERPROFILE first,
// and only if that's also unset/sentinel do we go to the user database.
func TestOpenClawHome_OSHomeNormalization(t *testing.T) {
isolateRuntimeWrites(t)
userProfileDir := t.TempDir()
homeWinsDir := t.TempDir()
tests := []struct {
name string
home string
userProfile string
want string
}{
{"HOME=undefined falls through to USERPROFILE", "undefined", userProfileDir, userProfileDir},
{"HOME=null falls through to USERPROFILE", "null", userProfileDir, userProfileDir},
{"HOME=whitespace falls through to USERPROFILE", " ", userProfileDir, userProfileDir},
{"HOME wins over USERPROFILE when both are valid", homeWinsDir, userProfileDir, homeWinsDir},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", tc.home)
t.Setenv("USERPROFILE", tc.userProfile)
t.Setenv("OPENCLAW_HOME", "")
if got := openClawHome(); got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd pins the
// deliberate hybrid documented on osHome: with HOME a sentinel literal
// and USERPROFILE unset, OpenClaw would fall back to process.cwd();
// this implementation falls to the OS user database instead. The
// account home is both safer (cwd-independent) and more useful (it is
// where the user originally authored `~/...` against), so we prefer it
// over either OpenClaw's cwd fallback or a strict reject.
func TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd(t *testing.T) {
isolateRuntimeWrites(t)
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
t.Setenv("HOME", "undefined")
t.Setenv("USERPROFILE", "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home, not cwd)", got, u.HomeDir)
}
}
// TestExpandTildePath_BackslashPreservedOnPOSIX pins that `~\secret.json`
// expands by replacing only the `~` byte, leaving the backslash literally
// as part of the filename — matching OpenClaw's regex-replace semantics
// (`/^~(?=$|[\\/])/`) rather than going through filepath.Join (which would
// drop the backslash on POSIX). On Windows backslash is a real separator,
// so the literal-byte invariant doesn't apply.
func TestExpandTildePath_BackslashPreservedOnPOSIX(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("backslash is a path separator on Windows; invariant only applies on POSIX")
}
fakeHome := t.TempDir()
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath(`~\secret.json`)
want := fakeHome + `\secret.json`
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (backslash should be preserved as filename byte)", `~\secret.json`, got, want)
}
}
+306
View File
@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// OpenClawRoot captures the minimal subset of openclaw.json needed by config bind.
// Unknown fields are silently ignored (forward-compatible with future OpenClaw versions).
type OpenClawRoot struct {
Channels ChannelsRoot `json:"channels"`
Secrets *SecretsConfig `json:"secrets,omitempty"`
}
// ChannelsRoot holds channel configurations.
type ChannelsRoot struct {
Feishu *FeishuChannel `json:"feishu,omitempty"`
}
// FeishuChannel represents the channels.feishu subtree.
// Single-account: AppID + AppSecret + Brand at top level.
// Multi-account: Accounts map (keyed by label like "work", "personal").
//
// Note: OpenClaw's canonical schema stores the brand under the key
// `domain` (values "feishu" | "lark"), not `brand`. The Go field name
// `Brand` stays aligned with our internal terminology, but the JSON
// tag matches OpenClaw's on-disk format.
type FeishuChannel struct {
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
Accounts map[string]*FeishuAccount `json:"accounts,omitempty"`
}
// FeishuAccount is a single account entry within Accounts.
// Like FeishuChannel, `Brand` maps to OpenClaw's `domain` key.
type FeishuAccount struct {
Enabled *bool `json:"enabled,omitempty"` // nil = default enabled
AppID string `json:"appId,omitempty"`
AppSecret SecretInput `json:"appSecret,omitempty"`
Brand string `json:"domain,omitempty"`
}
// isEnabled returns true if the enabled field is nil (default) or explicitly true.
func isEnabled(enabled *bool) bool {
return enabled == nil || *enabled
}
// SecretInput is a union type: either a plain string or a SecretRef object.
// Implements custom JSON unmarshaling to handle both forms.
type SecretInput struct {
Plain string // non-empty when value is a plain string (including "${VAR}" templates)
Ref *SecretRef // non-nil when value is a SecretRef object
}
// IsZero returns true if no value was provided.
func (s SecretInput) IsZero() bool {
return s.Plain == "" && s.Ref == nil
}
// IsPlain returns true if this is a plain string (not a SecretRef object).
func (s SecretInput) IsPlain() bool {
return s.Ref == nil
}
// SecretRef references a secret stored externally via OpenClaw's provider system.
type SecretRef struct {
Source string `json:"source"` // "env" | "file" | "exec"
Provider string `json:"provider,omitempty"` // provider alias; defaults to config.secrets.defaults.<source> or "default"
ID string `json:"id"` // lookup key (env var name / JSON pointer / exec ref id)
}
// validSources lists accepted SecretRef source values.
var validSources = map[string]bool{
"env": true,
"file": true,
"exec": true,
}
// EnvTemplateRe matches OpenClaw env template strings like "${FEISHU_APP_SECRET}".
// Only uppercase letters, digits, and underscores; 1-128 chars; must start with uppercase.
var EnvTemplateRe = regexp.MustCompile(`^\$\{([A-Z][A-Z0-9_]{0,127})\}$`)
// UnmarshalJSON handles both string and object forms of SecretInput.
func (s *SecretInput) UnmarshalJSON(data []byte) error {
// Try string first
var str string
if err := json.Unmarshal(data, &str); err == nil {
s.Plain = str
s.Ref = nil
return nil
}
// Try SecretRef object
var ref SecretRef
if err := json.Unmarshal(data, &ref); err == nil {
if !validSources[ref.Source] {
return fmt.Errorf("SecretRef.source must be env|file|exec, got %q", ref.Source)
}
if ref.ID == "" {
return fmt.Errorf("SecretRef.id must be non-empty")
}
s.Ref = &ref
s.Plain = ""
return nil
}
return fmt.Errorf("appSecret must be a string or {source, provider?, id} object")
}
// MarshalJSON serializes SecretInput back to JSON.
func (s SecretInput) MarshalJSON() ([]byte, error) {
if s.Ref != nil {
return json.Marshal(s.Ref)
}
return json.Marshal(s.Plain)
}
// SecretsConfig captures the secrets.providers registry from openclaw.json.
type SecretsConfig struct {
Providers map[string]*ProviderConfig `json:"providers,omitempty"`
Defaults *ProviderDefaults `json:"defaults,omitempty"`
}
// ProviderDefaults holds default provider aliases for each source type.
type ProviderDefaults struct {
Env string `json:"env,omitempty"`
File string `json:"file,omitempty"`
Exec string `json:"exec,omitempty"`
}
// DefaultProviderAlias is the fallback provider name when none is specified.
const DefaultProviderAlias = "default"
// ProviderConfig holds configuration for a secret provider.
// Fields are source-specific; unused fields for other sources are ignored.
type ProviderConfig struct {
Source string `json:"source"` // "env" | "file" | "exec"
// env source fields
Allowlist []string `json:"allowlist,omitempty"`
// file source fields
Path string `json:"path,omitempty"`
Mode string `json:"mode,omitempty"` // "singleValue" | "json"; default "json"
TimeoutMs int `json:"timeoutMs,omitempty"`
MaxBytes int `json:"maxBytes,omitempty"`
// exec source fields
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
NoOutputTimeoutMs int `json:"noOutputTimeoutMs,omitempty"`
MaxOutputBytes int `json:"maxOutputBytes,omitempty"`
JSONOnly *bool `json:"jsonOnly,omitempty"` // nil = default true
Env map[string]string `json:"env,omitempty"`
PassEnv []string `json:"passEnv,omitempty"`
TrustedDirs []string `json:"trustedDirs,omitempty"`
AllowInsecurePath bool `json:"allowInsecurePath,omitempty"`
AllowSymlinkCommand bool `json:"allowSymlinkCommand,omitempty"`
}
// Default values for provider config fields (aligned with OpenClaw resolve.ts).
const (
DefaultFileTimeoutMs = 5000
DefaultFileMaxBytes = 1024 * 1024 // 1 MiB
DefaultExecTimeoutMs = 10000
DefaultExecMaxOutputBytes = 1024 * 1024 // 1 MiB
)
// ResolveDefaultProvider returns the effective provider alias for a SecretRef.
// If ref.Provider is set, returns it; otherwise falls back to config defaults or "default".
func ResolveDefaultProvider(ref *SecretRef, cfg *SecretsConfig) string {
if ref.Provider != "" {
return ref.Provider
}
if cfg != nil && cfg.Defaults != nil {
switch ref.Source {
case "env":
if cfg.Defaults.Env != "" {
return cfg.Defaults.Env
}
case "file":
if cfg.Defaults.File != "" {
return cfg.Defaults.File
}
case "exec":
if cfg.Defaults.Exec != "" {
return cfg.Defaults.Exec
}
}
}
return DefaultProviderAlias
}
// LookupProvider resolves a provider config from the registry.
// Returns the provider config or an error if not found.
// Special case: env source with "default" provider returns a synthetic empty env provider.
func LookupProvider(ref *SecretRef, cfg *SecretsConfig) (*ProviderConfig, error) {
providerName := ResolveDefaultProvider(ref, cfg)
if cfg != nil && cfg.Providers != nil {
if pc, ok := cfg.Providers[providerName]; ok {
if pc == nil {
return nil, fmt.Errorf("secret provider %q is configured as null", providerName)
}
if pc.Source != ref.Source {
return nil, fmt.Errorf("secret provider %q has source %q but ref requests %q",
providerName, pc.Source, ref.Source)
}
return pc, nil
}
}
// Special case: default env provider (implicit, per OpenClaw resolve.ts)
if ref.Source == "env" && providerName == DefaultProviderAlias {
return &ProviderConfig{Source: "env"}, nil
}
return nil, fmt.Errorf("secret provider %q is not configured (ref: %s:%s:%s)",
providerName, ref.Source, providerName, ref.ID)
}
// CandidateApp represents a bindable app from OpenClaw's feishu channel config.
type CandidateApp struct {
Label string
AppID string
AppSecret SecretInput
Brand string
}
// ListCandidateApps enumerates all bindable (enabled) apps from a FeishuChannel.
// Disabled accounts (enabled: false) are filtered out.
func ListCandidateApps(ch *FeishuChannel) []CandidateApp {
if ch == nil {
return nil
}
if len(ch.Accounts) > 0 {
apps := make([]CandidateApp, 0, len(ch.Accounts)+1)
// When accounts exist AND top-level has its own appId+appSecret,
// include the top-level as a "default" candidate — aligned with
// openclaw-lark getLarkAccountIds() which adds DEFAULT_ACCOUNT_ID
// when top-level credentials are present and no explicit "default" exists.
hasDefault := false
for label := range ch.Accounts {
if strings.EqualFold(strings.TrimSpace(label), "default") {
hasDefault = true
break
}
}
if !hasDefault && ch.AppID != "" && !ch.AppSecret.IsZero() && isEnabled(ch.Enabled) {
apps = append(apps, CandidateApp{
Label: "default",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
})
}
for label, acct := range ch.Accounts {
if acct == nil || !isEnabled(acct.Enabled) {
continue // skip disabled accounts
}
appID := acct.AppID
if appID == "" {
appID = ch.AppID // inherit from top-level
}
if appID == "" {
continue // skip entries with no effective AppID
}
appSecret := acct.AppSecret
if appSecret.IsZero() {
appSecret = ch.AppSecret // inherit from top-level
}
brand := acct.Brand
if brand == "" {
brand = ch.Brand
}
apps = append(apps, CandidateApp{
Label: label,
AppID: appID,
AppSecret: appSecret,
Brand: brand,
})
}
return apps
}
// Single account at top level — check if channel itself is enabled
if ch.AppID != "" && isEnabled(ch.Enabled) {
return []CandidateApp{{
Label: "",
AppID: ch.AppID,
AppSecret: ch.AppSecret,
Brand: ch.Brand,
}}
}
return nil
}
+419
View File
@@ -0,0 +1,419 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"encoding/json"
"testing"
)
func TestSecretInput_MarshalJSON_PlainString(t *testing.T) {
input := SecretInput{Plain: "my_secret"}
data, err := input.MarshalJSON()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := `"my_secret"`
if string(data) != want {
t.Errorf("got %s, want %s", data, want)
}
}
func TestSecretInput_MarshalJSON_SecretRef(t *testing.T) {
input := SecretInput{Ref: &SecretRef{Source: "env", ID: "MY_VAR"}}
data, err := input.MarshalJSON()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var ref SecretRef
if err := json.Unmarshal(data, &ref); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if ref.Source != "env" {
t.Errorf("source = %q, want %q", ref.Source, "env")
}
if ref.ID != "MY_VAR" {
t.Errorf("id = %q, want %q", ref.ID, "MY_VAR")
}
}
func TestSecretInput_UnmarshalJSON_InvalidSource(t *testing.T) {
data := []byte(`{"source":"invalid","id":"key"}`)
var input SecretInput
err := json.Unmarshal(data, &input)
if err == nil {
t.Fatal("expected error for invalid source, got nil")
}
}
func TestSecretInput_UnmarshalJSON_EmptyID(t *testing.T) {
data := []byte(`{"source":"env","id":""}`)
var input SecretInput
err := json.Unmarshal(data, &input)
if err == nil {
t.Fatal("expected error for empty id, got nil")
}
}
func TestSecretInput_UnmarshalJSON_InvalidType(t *testing.T) {
data := []byte(`42`)
var input SecretInput
err := json.Unmarshal(data, &input)
if err == nil {
t.Fatal("expected error for numeric input, got nil")
}
want := "appSecret must be a string or {source, provider?, id} object"
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestResolveDefaultProvider_ExplicitProvider(t *testing.T) {
ref := &SecretRef{Source: "env", Provider: "my-custom", ID: "KEY"}
got := ResolveDefaultProvider(ref, nil)
if got != "my-custom" {
t.Errorf("got %q, want %q", got, "my-custom")
}
}
func TestResolveDefaultProvider_FromDefaults(t *testing.T) {
tests := []struct {
name string
source string
defaults *ProviderDefaults
want string
}{
{
name: "env default",
source: "env",
defaults: &ProviderDefaults{Env: "my-env-prov"},
want: "my-env-prov",
},
{
name: "file default",
source: "file",
defaults: &ProviderDefaults{File: "my-file-prov"},
want: "my-file-prov",
},
{
name: "exec default",
source: "exec",
defaults: &ProviderDefaults{Exec: "my-exec-prov"},
want: "my-exec-prov",
},
{
name: "no defaults configured",
source: "env",
defaults: &ProviderDefaults{},
want: DefaultProviderAlias,
},
{
name: "nil defaults",
source: "env",
defaults: nil,
want: DefaultProviderAlias,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ref := &SecretRef{Source: tt.source, ID: "KEY"}
cfg := &SecretsConfig{Defaults: tt.defaults}
got := ResolveDefaultProvider(ref, cfg)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestResolveDefaultProvider_NilConfig(t *testing.T) {
ref := &SecretRef{Source: "env", ID: "KEY"}
got := ResolveDefaultProvider(ref, nil)
if got != DefaultProviderAlias {
t.Errorf("got %q, want %q", got, DefaultProviderAlias)
}
}
func TestLookupProvider_SourceMismatch(t *testing.T) {
cfg := &SecretsConfig{
Providers: map[string]*ProviderConfig{
"default": {Source: "file"},
},
}
ref := &SecretRef{Source: "env", ID: "KEY"}
_, err := LookupProvider(ref, cfg)
if err == nil {
t.Fatal("expected error for source mismatch, got nil")
}
want := `secret provider "default" has source "file" but ref requests "env"`
if err.Error() != want {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
func TestLookupProvider_ImplicitDefaultEnv(t *testing.T) {
// Default env provider is implicitly available even without explicit config
ref := &SecretRef{Source: "env", ID: "KEY"}
pc, err := LookupProvider(ref, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pc.Source != "env" {
t.Errorf("source = %q, want %q", pc.Source, "env")
}
}
func TestListCandidateApps_NilChannel(t *testing.T) {
got := ListCandidateApps(nil)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestListCandidateApps_SingleAccount(t *testing.T) {
ch := &FeishuChannel{
AppID: "cli_single",
AppSecret: SecretInput{Plain: "secret"},
Brand: "feishu",
}
got := ListCandidateApps(ch)
if len(got) != 1 {
t.Fatalf("count = %d, want 1", len(got))
}
if got[0].AppID != "cli_single" {
t.Errorf("appId = %q, want %q", got[0].AppID, "cli_single")
}
if got[0].Label != "" {
t.Errorf("label = %q, want empty", got[0].Label)
}
if got[0].Brand != "feishu" {
t.Errorf("brand = %q, want %q", got[0].Brand, "feishu")
}
}
func TestListCandidateApps_SingleAccount_Disabled(t *testing.T) {
disabled := false
ch := &FeishuChannel{
Enabled: &disabled,
AppID: "cli_disabled",
AppSecret: SecretInput{Plain: "secret"},
}
got := ListCandidateApps(ch)
if len(got) != 0 {
t.Errorf("expected 0 apps for disabled channel, got %d", len(got))
}
}
func TestListCandidateApps_MultiAccount_InheritTopLevel(t *testing.T) {
ch := &FeishuChannel{
AppID: "cli_top_level",
Brand: "lark",
Accounts: map[string]*FeishuAccount{
"work": {
// No AppID → inherits from top-level
AppSecret: SecretInput{Plain: "secret"},
// No Brand → inherits from top-level
},
},
}
got := ListCandidateApps(ch)
if len(got) != 1 {
t.Fatalf("count = %d, want 1", len(got))
}
if got[0].AppID != "cli_top_level" {
t.Errorf("inherited appId = %q, want %q", got[0].AppID, "cli_top_level")
}
if got[0].Brand != "lark" {
t.Errorf("inherited brand = %q, want %q", got[0].Brand, "lark")
}
if got[0].Label != "work" {
t.Errorf("label = %q, want %q", got[0].Label, "work")
}
}
func TestListCandidateApps_MultiAccount_InheritAppSecret(t *testing.T) {
// Reproduces the "default": {} edge case from real openclaw.json configs
// where an empty account object should inherit appSecret from the top-level channel.
ch := &FeishuChannel{
AppID: "cli_fake_top_level",
AppSecret: SecretInput{Plain: "fake_top_level_secret"},
Brand: "feishu",
Accounts: map[string]*FeishuAccount{
"default": {}, // empty — should inherit everything from top-level
"other": {
Enabled: boolPtr(true),
AppID: "cli_fake_other",
AppSecret: SecretInput{Plain: "fake_other_secret"},
},
},
}
got := ListCandidateApps(ch)
if len(got) != 2 {
t.Fatalf("count = %d, want 2", len(got))
}
// Find the "default" account
var def *CandidateApp
for i := range got {
if got[i].Label == "default" {
def = &got[i]
}
}
if def == nil {
t.Fatal("default account not found in candidates")
}
if def.AppID != "cli_fake_top_level" {
t.Errorf("default appId = %q, want inherited top-level", def.AppID)
}
if def.AppSecret.IsZero() {
t.Error("default appSecret should inherit from top-level, got zero")
}
if def.AppSecret.Plain != "fake_top_level_secret" {
t.Errorf("default appSecret = %q, want inherited top-level", def.AppSecret.Plain)
}
if def.Brand != "feishu" {
t.Errorf("default brand = %q, want inherited top-level", def.Brand)
}
}
func TestListCandidateApps_ImplicitDefault_WhenTopLevelHasCredentials(t *testing.T) {
// When accounts exist but none is named "default", and top-level has
// its own appId+appSecret, the top-level should be included as a
// synthetic "default" candidate (aligned with openclaw-lark plugin).
ch := &FeishuChannel{
AppID: "cli_top",
AppSecret: SecretInput{Plain: "top_secret"},
Brand: "feishu",
Accounts: map[string]*FeishuAccount{
"ethan": {
AppID: "cli_ethan",
AppSecret: SecretInput{Plain: "ethan_secret"},
Brand: "lark",
},
},
}
got := ListCandidateApps(ch)
if len(got) != 2 {
t.Fatalf("count = %d, want 2 (default + ethan)", len(got))
}
var def, ethan *CandidateApp
for i := range got {
switch got[i].Label {
case "default":
def = &got[i]
case "ethan":
ethan = &got[i]
}
}
if def == nil {
t.Fatal("implicit default candidate not found")
}
if def.AppID != "cli_top" {
t.Errorf("default appId = %q, want %q", def.AppID, "cli_top")
}
if ethan == nil {
t.Fatal("ethan candidate not found")
}
if ethan.AppID != "cli_ethan" {
t.Errorf("ethan appId = %q, want %q", ethan.AppID, "cli_ethan")
}
}
func TestListCandidateApps_NoImplicitDefault_WhenExplicitDefaultExists(t *testing.T) {
// When accounts already contain a "default" entry, don't duplicate it.
ch := &FeishuChannel{
AppID: "cli_top",
AppSecret: SecretInput{Plain: "top_secret"},
Accounts: map[string]*FeishuAccount{
"default": {}, // inherits top-level
"other": {AppID: "cli_other", AppSecret: SecretInput{Plain: "s"}},
},
}
got := ListCandidateApps(ch)
defaultCount := 0
for _, c := range got {
if c.Label == "default" {
defaultCount++
}
}
if defaultCount != 1 {
t.Errorf("expected exactly 1 default candidate, got %d", defaultCount)
}
}
func TestListCandidateApps_NoImplicitDefault_WhenTopLevelMissingSecret(t *testing.T) {
// Top-level has appId but no appSecret → no implicit default.
ch := &FeishuChannel{
AppID: "cli_top",
// no appSecret
Accounts: map[string]*FeishuAccount{
"ethan": {AppID: "cli_ethan", AppSecret: SecretInput{Plain: "s"}},
},
}
got := ListCandidateApps(ch)
if len(got) != 1 {
t.Fatalf("count = %d, want 1 (only ethan)", len(got))
}
if got[0].Label != "ethan" {
t.Errorf("label = %q, want %q", got[0].Label, "ethan")
}
}
func boolPtr(v bool) *bool { return &v }
func TestListCandidateApps_MultiAccount_DisabledFiltered(t *testing.T) {
disabled := false
ch := &FeishuChannel{
Accounts: map[string]*FeishuAccount{
"active": {
AppID: "cli_active",
AppSecret: SecretInput{Plain: "secret"},
},
"disabled": {
Enabled: &disabled,
AppID: "cli_disabled",
AppSecret: SecretInput{Plain: "secret"},
},
"nil_acct": nil,
},
}
got := ListCandidateApps(ch)
if len(got) != 1 {
t.Fatalf("count = %d, want 1 (disabled and nil filtered out)", len(got))
}
if got[0].AppID != "cli_active" {
t.Errorf("appId = %q, want %q", got[0].AppID, "cli_active")
}
}
func TestListCandidateApps_EmptyAppID(t *testing.T) {
ch := &FeishuChannel{
AppID: "",
// No accounts, no appId → no candidates
}
got := ListCandidateApps(ch)
if len(got) != 0 {
t.Errorf("expected 0 apps for empty appId, got %d", len(got))
}
}
func TestIsEnabled_Nil(t *testing.T) {
if !isEnabled(nil) {
t.Error("nil should default to enabled")
}
}
func TestIsEnabled_True(t *testing.T) {
v := true
if !isEnabled(&v) {
t.Error("explicit true should be enabled")
}
}
func TestIsEnabled_False(t *testing.T) {
v := false
if isEnabled(&v) {
t.Error("explicit false should be disabled")
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package build
import "runtime/debug"
// Version is dynamically set by -ldflags or falls back to module info.
var Version = "DEV"
// Date is the build date in YYYY-MM-DD format, set by -ldflags.
var Date = ""
func init() {
if Version == "DEV" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" {
Version = info.Main.Version
}
}
if Version == "" {
Version = "DEV"
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package charcheck provides character-level security checks shared across
// path validation (localfileio) and input validation (validate) packages.
// Keeping these checks in one place ensures consistent detection of dangerous
// Unicode and control characters throughout the codebase.
package charcheck
import "fmt"
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters (Bidi overrides, zero-width, line/paragraph
// separators) that enable visual spoofing attacks.
func RejectControlChars(value, flagName string) error {
for _, r := range value {
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("%s contains invalid control characters", flagName)
}
if IsDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
}
}
return nil
}
// IsDangerousUnicode identifies Unicode code points used for visual spoofing
// attacks. These characters are invisible or alter text direction, allowing
// attackers to make "report.exe" display as "report.txt" (Bidi override) or
// insert hidden content (zero-width characters).
func IsDangerousUnicode(r rune) bool {
switch {
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
return true
case r == 0xFEFF: // BOM / ZWNBSP
return true
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
return true
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
return true
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
return true
}
return false
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"net"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
)
// rawAPIJSONHint guides users when an SDK or response body parse fails. The
// most common cause is a non-JSON payload (file download endpoint hit without
// `--output`, or an upstream HTML error page).
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
// WrapDoAPIError converts SDK-boundary failures into typed errs.* errors:
// already-typed errors pass through (idempotent), JSON-decode failures
// become InternalError{SubtypeInvalidResponse}, everything else becomes
// NetworkError with a chain-derived subtype (timeout / tls / dns /
// server_error / transport-fallback).
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
// (1) Pass-through any typed errs.* error.
if _, ok := errs.ProblemOf(err); ok {
return err
}
// (2) JSON-decode failure at the SDK boundary → InternalError.
if isJSONDecodeError(err) {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"SDK returned an invalid JSON response: %v", err).
WithHint("%s", rawAPIJSONHint).
WithCause(err)
}
// (3) Otherwise classify as a network failure with a chain-derived subtype.
return errs.NewNetworkError(classifyNetworkSubtype(err),
"API call failed: %v", err).
WithCause(err)
}
// WrapJSONResponseParseError lifts a response-layer JSON parse failure into
// *errs.InternalError{Subtype: SubtypeInvalidResponse}. Empty body, malformed
// JSON, and mid-stream EOFs all collapse to this single shape.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil
}
var e *errs.InternalError
if len(bytes.TrimSpace(body)) == 0 {
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an empty JSON response body")
} else {
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err)
}
return e.WithHint("%s", rawAPIJSONHint).WithCause(err)
}
// classifyNetworkSubtype maps an error chain to one of the network subtypes,
// falling back to SubtypeNetworkTransport. Timeout is checked first because
// a net.OpError can satisfy net.Error and also wrap a DNS sub-error in
// pathological proxy configurations — we prefer the timeout signal.
func classifyNetworkSubtype(err error) errs.Subtype {
// (a) Timeout — net.Error.Timeout(), plus the SDK's typed timeout
// errors (which do not implement net.Error).
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return errs.SubtypeNetworkTimeout
}
var sdkServerTimeout *larkcore.ServerTimeoutError
if errors.As(err, &sdkServerTimeout) {
return errs.SubtypeNetworkTimeout
}
var sdkClientTimeout *larkcore.ClientTimeoutError
if errors.As(err, &sdkClientTimeout) {
return errs.SubtypeNetworkTimeout
}
// (b) TLS — typed x509 error or message substring fallback.
var x509Err *x509.UnknownAuthorityError
if errors.As(err, &x509Err) {
return errs.SubtypeNetworkTLS
}
msg := err.Error()
if strings.Contains(msg, "x509:") || strings.Contains(msg, "tls:") {
return errs.SubtypeNetworkTLS
}
// (c) DNS — *net.DNSError covers SDK chains coming from net.Dialer.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return errs.SubtypeNetworkDNS
}
// HTTP 5xx classification lives on the call sites with *http.Response
// access (DoStream, HandleResponse); the SDK never surfaces non-504 5xx
// as an error here.
return errs.SubtypeNetworkTransport
}
// isJSONDecodeError reports whether err is a JSON decode failure at the
// SDK boundary, matching both typed json errors and their fmt.Errorf-
// wrapped substring form. io.EOF is intentionally excluded — at the SDK
// boundary an EOF is a transport failure, not a payload-shape failure.
func isJSONDecodeError(err error) bool {
var syntaxErr *json.SyntaxError
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
return true
}
// Substring fallback for fmt.Errorf-wrapped json decode errors that no
// longer satisfy errors.As against the typed json errors. "invalid
// character" alone is too broad (other libraries surface it for non-
// JSON failures), so it is gated on the message also containing "json".
msg := err.Error()
if strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "cannot unmarshal") {
return true
}
lower := strings.ToLower(msg)
return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json")
}
+311
View File
@@ -0,0 +1,311 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
)
// ─────────────────────────────────────────────────────────────────────────────
// WrapDoAPIError: typed error contract.
//
// Pass-through: any error carrying *errs.Problem (detected via ProblemOf).
// JSON decode failures → *errs.InternalError{Subtype: invalid_response}.
// Otherwise → *errs.NetworkError with one of: timeout / tls / dns /
// server_error / transport (fallback).
// ─────────────────────────────────────────────────────────────────────────────
// timeoutNetError implements net.Error with Timeout() == true. Used to exercise
// the timeout branch of the network classifier without depending on a live
// transport.
type timeoutNetError struct{}
func (timeoutNetError) Error() string { return "i/o timeout" }
func (timeoutNetError) Timeout() bool { return true }
func (timeoutNetError) Temporary() bool { return true }
// TestWrapDoAPIError_SyntaxError_ReturnsInternalError pins that a raw
// *json.SyntaxError from the SDK boundary surfaces as an *errs.InternalError
// with Subtype=invalid_response — replacing the legacy api_error envelope.
func TestWrapDoAPIError_SyntaxError_ReturnsInternalError(t *testing.T) {
got := WrapDoAPIError(&json.SyntaxError{Offset: 1})
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T (%v)", got, got)
}
if ie.Category != errs.CategoryInternal {
t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
}
}
// TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError pins the second
// json-decode error variant (type-mismatch decoding) routes through the same
// invalid_response branch — not the network fallback.
func TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError(t *testing.T) {
got := WrapDoAPIError(&json.UnmarshalTypeError{Value: "string", Type: nil})
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T", got)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
}
}
// TestWrapDoAPIError_Timeout pins that an SDK transport error whose chain
// carries a net.Error with Timeout()==true classifies as
// NetworkError{Subtype: timeout}. Covers the E2E timeout scenario
// (HTTPS_PROXY pointing at a non-routable address).
func TestWrapDoAPIError_Timeout(t *testing.T) {
got := WrapDoAPIError(&net.OpError{Op: "dial", Net: "tcp", Err: timeoutNetError{}})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T (%v)", got, got)
}
if ne.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
}
if ne.Category != errs.CategoryNetwork {
t.Errorf("Category = %v, want %v", ne.Category, errs.CategoryNetwork)
}
}
// TestWrapDoAPIError_TLS pins that an x509.UnknownAuthorityError classifies
// as NetworkError{Subtype: tls}.
func TestWrapDoAPIError_TLS(t *testing.T) {
got := WrapDoAPIError(&x509.UnknownAuthorityError{})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTLS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
}
}
// TestWrapDoAPIError_TLS_HandshakeMessage covers the message-substring fallback
// for TLS errors that don't surface as a typed x509 error.
func TestWrapDoAPIError_TLS_HandshakeMessage(t *testing.T) {
got := WrapDoAPIError(errors.New("remote error: tls: handshake failure"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTLS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
}
}
// TestWrapDoAPIError_DNS pins that a *net.DNSError classifies as
// NetworkError{Subtype: dns}.
func TestWrapDoAPIError_DNS(t *testing.T) {
got := WrapDoAPIError(&net.DNSError{Name: "example.invalid"})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkDNS {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkDNS)
}
}
// TestWrapDoAPIError_SDKServerTimeout pins that a *larkcore.ServerTimeoutError
// (504 Gateway Timeout surfaced by the SDK as a typed error rather than an
// *http.Response) classifies as timeout — upstream took too long to respond.
func TestWrapDoAPIError_SDKServerTimeout(t *testing.T) {
got := WrapDoAPIError(&larkcore.ServerTimeoutError{})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
}
}
// TestWrapDoAPIError_SDKClientTimeout pins that a *larkcore.ClientTimeoutError
// (client-side request timeout the SDK reports without satisfying net.Error)
// classifies as timeout.
func TestWrapDoAPIError_SDKClientTimeout(t *testing.T) {
got := WrapDoAPIError(&larkcore.ClientTimeoutError{})
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
}
}
// TestWrapDoAPIError_UnknownCause_FallsBackToTransport pins the fallback:
// when none of the specific causes match, NetworkError uses the generic
// transport subtype.
func TestWrapDoAPIError_UnknownCause_FallsBackToTransport(t *testing.T) {
got := WrapDoAPIError(errors.New("connection reset by peer"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Subtype = %v, want %v (fallback)", ne.Subtype, errs.SubtypeNetworkTransport)
}
}
// TestWrapDoAPIError_PassThrough_TypedError pins that any typed *errs.* error
// (carrying an embedded Problem) passes through unchanged — same pointer
// identity, no re-classification. This is the load-bearing invariant for
// resolveAccessToken returning *errs.AuthenticationError through DoSDKRequest.
func TestWrapDoAPIError_PassThrough_TypedError(t *testing.T) {
cases := []error{
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing, Message: "no token"}},
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Message: "no scope"}},
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport, Message: "transport"}},
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, Message: "sdk"}},
}
for _, in := range cases {
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
got := WrapDoAPIError(in)
if got != in {
t.Fatalf("expected identity pass-through, got %T %v", got, got)
}
})
}
}
// TestWrapDoAPIError_Nil pins that nil in stays nil out (no allocation, no
// panic). Callers rely on this when the SDK returns success.
func TestWrapDoAPIError_Nil(t *testing.T) {
if got := WrapDoAPIError(nil); got != nil {
t.Errorf("WrapDoAPIError(nil) = %v, want nil", got)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WrapJSONResponseParseError: typed error contract.
//
// All response-layer parse failures (empty body, malformed JSON, mid-stream
// read failures that surface as parse errors) collapse to a single
// *errs.InternalError{Subtype: invalid_response}. The rawAPIJSONHint is
// preserved on Problem.Hint so users still get the "may have returned an
// empty or non-standard body, rerun with --output" guidance.
// ─────────────────────────────────────────────────────────────────────────────
// TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError pins the
// new shape for malformed JSON bodies — replaces the legacy api_error path.
func TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError(t *testing.T) {
got := WrapJSONResponseParseError(&json.SyntaxError{Offset: 1}, []byte("{ malformed"))
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T", got)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
}
if ie.Hint != rawAPIJSONHint {
t.Errorf("Hint = %q, want rawAPIJSONHint preserved", ie.Hint)
}
}
// TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError pins that
// empty / whitespace-only response bodies also surface as invalid_response,
// not as a network error. Endpoints returning only "\n" or "" trigger this.
func TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError(t *testing.T) {
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("body=%q: expected *errs.InternalError, got %T", body, got)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("body=%q: Subtype = %v, want invalid_response", body, ie.Subtype)
}
}
}
// TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError pins that
// io.ErrUnexpectedEOF mid-decode also surfaces as invalid_response — keeps
// the legacy non-empty-body decode-failure semantics under the new typed
// envelope.
func TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError(t *testing.T) {
got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("expected *errs.InternalError, got %T", got)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want invalid_response", ie.Subtype)
}
}
// TestWrapJSONResponseParseError_Nil pins nil pass-through.
func TestWrapJSONResponseParseError_Nil(t *testing.T) {
if got := WrapJSONResponseParseError(nil, []byte("anything")); got != nil {
t.Errorf("WrapJSONResponseParseError(nil, ...) = %v, want nil", got)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Cross-cutting: existing tests already in this file (kept and adjusted below).
// ─────────────────────────────────────────────────────────────────────────────
// TestWrapDoAPIError_UntypedErrorRoutesToNetwork pins that a plain untyped
// error (no embedded Problem, no JSON-decode chain) is NOT pass-through —
// only typed *errs.* values are. It routes to the network branch with the
// fallback transport subtype.
func TestWrapDoAPIError_UntypedErrorRoutesToNetwork(t *testing.T) {
got := WrapDoAPIError(errors.New("no access token available for user"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError for an untyped error, got %T (%v)", got, got)
}
// Sanity: not silently re-classified as JSON-decode.
var ie *errs.InternalError
if errors.As(got, &ie) {
t.Fatalf("expected NetworkError, got InternalError %v", ie)
}
}
// TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins pins that a typed
// *errs.AuthenticationError wrapping a JSON syntax error in its chain still
// passes through as the outer type — we never re-classify a typed problem
// carrier just because the chain contains a json.SyntaxError. Forward-compat
// for credential chain errors that bundle a parse failure as Cause.
func TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins(t *testing.T) {
jsonErr := &json.SyntaxError{Offset: 1}
outer := &errs.AuthenticationError{
Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired, Message: "expired"},
Cause: jsonErr,
}
got := WrapDoAPIError(outer)
if got != outer {
t.Fatalf("expected outer typed error to win, got %T %v", got, got)
}
}
// TestWrapDoAPIError_MessageContainsCause pins that the wrapped error's
// message is carried into Problem.Message so logs / debugging retain the
// underlying cause string.
func TestWrapDoAPIError_MessageContainsCause(t *testing.T) {
raw := errors.New("dial tcp 10.0.0.1:443: i/o timeout")
got := WrapDoAPIError(raw)
if !strings.Contains(got.Error(), "i/o timeout") {
t.Errorf("Error() = %q, want to contain underlying cause", got.Error())
}
}
+507
View File
@@ -0,0 +1,507 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
// RawApiRequest describes a raw API request.
type RawApiRequest struct {
Method string
URL string
Params map[string]interface{}
Data interface{}
As core.Identity
ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers)
}
// APIClient wraps lark.Client for all Lark Open API calls.
type APIClient struct {
Config *core.CliConfig
SDK *lark.Client // All Lark API calls go through SDK
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
ErrOut io.Writer // debug/progress output
Credential *credential.CredentialProvider
}
func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (string, error) {
result, err := c.Credential.ResolveToken(ctx, credential.NewTokenSpec(as, c.Config.AppID))
if err != nil {
var unavailableErr *credential.TokenUnavailableError
if errors.As(err, &unavailableErr) {
return "", newTokenMissingError(as, unavailableErr)
}
// The credential chain already emits a typed *errs.AuthenticationError
// for the missing-UAT case (e.g. UAT refresh returned
// need_user_authorization), so it flows through unchanged: the
// outer-typed gate in cmd/root.go and the idempotent WrapDoAPIError
// both preserve its authentication category and exit 3.
return "", err
}
if result.Token == "" {
return "", newTokenMissingError(as, nil)
}
return result.Token, nil
}
// newTokenMissingError builds the typed *errs.AuthenticationError that
// resolveAccessToken returns when no usable token is available for the
// requested identity. cause is the underlying credential-chain error (or nil
// for the defensive empty-token branch) and is preserved for errors.Is /
// errors.Unwrap traversal without being serialized on the wire.
func newTokenMissingError(as core.Identity, cause error) error {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no access token available for %s", as).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(cause)
}
// buildApiReq converts a RawApiRequest into SDK types and collects
// request-specific options (ExtraOpts, URL-based headers).
// Auth is handled separately by DoSDKRequest.
func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []larkcore.RequestOptionFunc) {
queryParams := make(larkcore.QueryParams)
for k, v := range request.Params {
switch val := v.(type) {
case []string:
queryParams[k] = val
case []interface{}:
for _, item := range val {
queryParams.Add(k, fmt.Sprintf("%v", item))
}
default:
queryParams.Set(k, fmt.Sprintf("%v", v))
}
}
apiReq := &larkcore.ApiReq{
HttpMethod: strings.ToUpper(request.Method),
ApiPath: request.URL,
Body: request.Data,
QueryParams: queryParams,
}
var opts []larkcore.RequestOptionFunc
opts = append(opts, request.ExtraOpts...)
return apiReq, opts
}
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
//
// SDK Do() failures are normalised through WrapDoAPIError so every caller
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
// each one remembering to wrap. WrapDoAPIError classifies a raw transport
// failure into a typed *errs.NetworkError / *errs.InternalError per the
// contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
// WrapDoAPIError is idempotent on already-classified errors:
// the typed *errs.AuthenticationError that resolveAccessToken returns
// for missing tokens passes through with its auth category and exit 3
// intact, and any other typed *errs.* error from the credential chain
// survives the same way. Only stray untyped errors (raw fmt.Errorf)
// get the transport-or-internal fallback.
return nil, WrapDoAPIError(err)
}
if as.IsBot() {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
opts = append(opts, larkcore.WithTenantAccessToken(token))
} else {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
opts = append(opts, larkcore.WithUserAccessToken(token))
}
opts = append(opts, extraOpts...)
resp, err := c.SDK.Do(ctx, req, opts...)
if err != nil {
return nil, WrapDoAPIError(err)
}
return resp, nil
}
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
// a live *http.Response whose Body is an io.Reader for streaming consumption.
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
// any extra headers from opts are applied automatically.
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
cfg := buildConfig(opts)
// Resolve auth
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
// See DoSDKRequest comment on the same wrap pattern; the typed
// auth-error pass-through plus untyped fallback applies equally to
// streaming requests.
return nil, WrapDoAPIError(err)
}
// Build URL
requestURL, err := buildStreamURL(c.Config.Brand, req)
if err != nil {
return nil, err
}
// Build body
bodyReader, contentType, err := buildStreamBody(req.Body)
if err != nil {
return nil, err
}
// Timeout — use context deadline only; httpClient.Timeout would cut off
// healthy streaming responses because it includes body read time.
httpClient := *c.HTTP
httpClient.Timeout = 0
cancel := func() {}
requestCtx := ctx
if cfg.timeout > 0 {
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
requestCtx, cancel = context.WithTimeout(ctx, cfg.timeout)
}
}
// Build request
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
if err != nil {
cancel()
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "stream request failed: %s", err).WithCause(err)
}
// Apply headers from opts
for k, vs := range cfg.headers {
for _, v := range vs {
httpReq.Header.Add(k, v)
}
}
if contentType != "" {
httpReq.Header.Set("Content-Type", contentType)
}
httpReq.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
// Handle HTTP errors internally
if resp.StatusCode >= 400 {
defer resp.Body.Close()
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(errBody))
subtype := errs.SubtypeNetworkTransport
if resp.StatusCode >= 500 {
subtype = errs.SubtypeNetworkServer
}
var netErr *errs.NetworkError
if msg != "" {
netErr = errs.NewNetworkError(subtype, "HTTP %d: %s", resp.StatusCode, msg)
} else {
netErr = errs.NewNetworkError(subtype, "HTTP %d", resp.StatusCode)
}
netErr = netErr.WithCode(resp.StatusCode)
if logID := streamLogID(resp.Header); logID != "" {
netErr = netErr.WithLogID(logID)
}
return nil, netErr
}
return resp, nil
}
func streamLogID(header http.Header) string {
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
}
return logID
}
type cancelOnCloseBody struct {
io.ReadCloser
cancel context.CancelFunc
}
func (r *cancelOnCloseBody) Close() error {
err := r.ReadCloser.Close()
if r.cancel != nil {
r.cancel()
}
return err
}
func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) {
requestURL := req.ApiPath
if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") {
var pathSegs []string
for _, segment := range strings.Split(req.ApiPath, "/") {
if !strings.HasPrefix(segment, ":") {
pathSegs = append(pathSegs, segment)
continue
}
pathKey := strings.TrimPrefix(segment, ":")
pathValue, ok := req.PathParams[pathKey]
if !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
}
if pathValue == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "empty path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
}
pathSegs = append(pathSegs, url.PathEscape(pathValue))
}
endpoints := core.ResolveEndpoints(brand)
requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/")
}
if query := req.QueryParams.Encode(); query != "" {
requestURL += "?" + query
}
return requestURL, nil
}
func buildStreamBody(body interface{}) (io.Reader, string, error) {
switch typed := body.(type) {
case nil:
return nil, "", nil
case io.Reader:
return typed, "", nil
case []byte:
return bytes.NewReader(typed), "", nil
case string:
return strings.NewReader(typed), "text/plain; charset=utf-8", nil
default:
payload, err := json.Marshal(typed)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "failed to encode request body: %s", err).WithCause(err)
}
return bytes.NewReader(payload), "application/json", nil
}
}
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and
// any endpoint whose Content-Type may not be JSON.
func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore.ApiResp, error) {
apiReq, extraOpts := c.buildApiReq(request)
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
}
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. Use DoAPI
// directly when the response may not be JSON (e.g. file downloads).
//
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
// see a typed *errs.InternalError (invalid_response) instead of a bare
// fmt.Errorf — otherwise an empty or malformed page body would surface to the
// root handler as a plain-text "Error: ..." line and bypass the JSON stderr
// envelope contract.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
return nil, err
}
result, parseErr := ParseJSONResponse(resp)
if parseErr != nil {
return nil, WrapJSONResponseParseError(parseErr, resp.RawBody)
}
return result, nil
}
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{}) error) ([]interface{}, error) {
var allResults []interface{}
var pageToken string
page := 0
pageDelay := opts.PageDelay
if pageDelay == 0 {
pageDelay = 200
}
for {
page++
params := make(map[string]interface{})
for k, v := range request.Params {
params[k] = v
}
if pageToken != "" {
params["page_token"] = pageToken
}
fmt.Fprintf(c.ErrOut, "[page %d] fetching...\n", page)
result, err := c.CallAPI(ctx, RawApiRequest{
Method: request.Method,
URL: request.URL,
Params: params,
Data: request.Data,
As: request.As,
ExtraOpts: request.ExtraOpts,
})
if err != nil {
if page == 1 {
return nil, err
}
fmt.Fprintf(c.ErrOut, "[page %d] error, stopping pagination\n", page)
break
}
if resultMap, ok := result.(map[string]interface{}); ok {
code, _ := util.ToFloat64(resultMap["code"])
if code != 0 {
allResults = append(allResults, result)
if page == 1 {
return allResults, nil
}
fmt.Fprintf(c.ErrOut, "[page %d] API error (code=%.0f), stopping pagination\n", page, code)
break
}
}
if onResult != nil {
if err := onResult(result); err != nil {
return allResults, err
}
}
allResults = append(allResults, result)
pageToken = ""
if resultMap, ok := result.(map[string]interface{}); ok {
if data, ok := resultMap["data"].(map[string]interface{}); ok {
hasMore, _ := data["has_more"].(bool)
if hasMore {
if pt, ok := data["page_token"].(string); ok && pt != "" {
pageToken = pt
} else if pt, ok := data["next_page_token"].(string); ok && pt != "" {
pageToken = pt
}
}
}
}
if pageToken == "" {
break
}
if opts.PageLimit > 0 && page >= opts.PageLimit {
fmt.Fprintf(c.ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", opts.PageLimit)
break
}
if pageDelay > 0 {
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
}
}
return allResults, nil
}
// PaginateAll fetches all pages and returns a single merged result.
// Use this for formats that need the complete dataset (e.g. JSON).
func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts PaginationOptions) (interface{}, error) {
results, err := c.paginateLoop(ctx, request, opts, nil)
if err != nil {
return nil, err
}
if len(results) == 0 {
return map[string]interface{}{}, nil
}
if len(results) == 1 {
return results[0], nil
}
return mergePagedResults(c.ErrOut, results), nil
}
// StreamPages fetches all pages and streams each page's list items via onItems.
// Returns the last page result (for error checking), whether any list items were found,
// and any network error. Use this for streaming formats (ndjson, table, csv).
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}) error, opts PaginationOptions) (result interface{}, hasItems bool, err error) {
totalItems := 0
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) error {
resultMap, ok := r.(map[string]interface{})
if !ok {
return nil
}
data, ok := resultMap["data"].(map[string]interface{})
if !ok {
return nil
}
arrayField := output.FindArrayField(data)
if arrayField == "" {
return nil
}
items, ok := data[arrayField].([]interface{})
if !ok {
return nil
}
totalItems += len(items)
if err := onItems(items); err != nil {
return err
}
hasItems = true
return nil
})
if loopErr != nil {
return nil, false, loopErr
}
if hasItems {
fmt.Fprintf(c.ErrOut, "[pagination] streamed %d pages, %d total items\n", len(results), totalItems)
}
if len(results) > 0 {
return results[len(results)-1], hasItems, nil
}
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
}
// CheckResponse inspects a Lark API response for business-level errors (non-zero code)
// and routes the result through errclass.BuildAPIError so the wire envelope carries
// the canonical Category/Subtype + identity-aware extension fields (MissingScopes,
// ConsoleURL, etc.) for known Lark codes; unknown codes still surface as
// *errs.APIError{Subtype: unknown}.
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil
}
if code, _ := util.ToFloat64(resultMap["code"]); code == 0 {
return nil
}
cc := errclass.ClassifyContext{Identity: string(identity)}
if c != nil && c.Config != nil {
cc.Brand = string(c.Config.Brand)
cc.AppID = c.Config.AppID
}
return errclass.BuildAPIError(resultMap, cc)
}
+713
View File
@@ -0,0 +1,713 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
)
// roundTripFunc is an adapter to use a function as http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
// jsonResponse creates an HTTP response with JSON body.
func jsonResponse(body interface{}) *http.Response {
b, _ := json.Marshal(body)
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(b)),
}
}
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
errBuf := &bytes.Buffer{}
httpClient := &http.Client{Transport: rt}
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpClient),
)
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
return &APIClient{
SDK: sdk,
ErrOut: errBuf,
Credential: testCred,
Config: cfg,
}, errBuf
}
func TestIsJSONContentType(t *testing.T) {
tests := []struct {
ct string
want bool
}{
{"application/json", true},
{"application/json; charset=utf-8", true},
{"text/json", true},
{"application/octet-stream", false},
{"image/png", false},
{"text/html", false},
{"", false},
}
for _, tt := range tests {
if got := IsJSONContentType(tt.ct); got != tt.want {
t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want)
}
}
}
func TestMimeToExt(t *testing.T) {
tests := []struct {
ct string
want string
}{
{"image/png", ".png"},
{"image/jpeg", ".jpg"},
{"application/pdf", ".pdf"},
{"text/plain", ".txt"},
{"application/octet-stream", ".bin"},
{"", ".bin"},
}
for _, tt := range tests {
if got := mimeToExt(tt.ct); got != tt.want {
t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want)
}
}
}
func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"user_id": "u123",
"name": "Test User",
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users/u123",
As: "bot",
}, func(items []interface{}) error {
t.Error("onItems should not be called for non-batch API")
return nil
}, PaginationOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasItems {
t.Error("expected hasItems=false for non-batch API")
}
if strings.Contains(errBuf.String(), "[pagination] streamed") {
t.Error("expected no pagination summary log for non-batch API")
}
if result == nil {
t.Fatal("expected non-nil result")
}
resultMap, ok := result.(map[string]interface{})
if !ok {
t.Fatal("expected result to be a map")
}
data, _ := resultMap["data"].(map[string]interface{})
if data["user_id"] != "u123" {
t.Errorf("expected user_id=u123, got %v", data["user_id"])
}
}
func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
"has_more": false,
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
var streamedItems []interface{}
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, func(items []interface{}) error {
streamedItems = append(streamedItems, items...)
return nil
}, PaginationOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasItems {
t.Error("expected hasItems=true for batch API")
}
if len(streamedItems) != 2 {
t.Errorf("expected 2 streamed items, got %d", len(streamedItems))
}
if !strings.Contains(errBuf.String(), "[pagination] streamed") {
t.Error("expected pagination summary log for batch API")
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestStreamPages_OnItemsErrorStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiCalls++
if apiCalls == 1 {
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": true,
"page_token": "next",
},
}), nil
}
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "2"}},
"has_more": false,
},
}), nil
})
ac, _ := newTestAPIClient(t, rt)
sentinel := errors.New("stop streaming")
var streamedItems []interface{}
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, func(items []interface{}) error {
streamedItems = append(streamedItems, items...)
return sentinel
}, PaginationOptions{PageDelay: 0})
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want sentinel", err)
}
if result != nil {
t.Fatalf("result = %#v, want nil when callback stops pagination", result)
}
if hasItems {
t.Fatal("hasItems = true, want false when callback stops before returning")
}
if apiCalls != 1 {
t.Fatalf("apiCalls = %d, want early stop after first page", apiCalls)
}
if len(streamedItems) != 1 {
t.Fatalf("streamedItems = %d, want first page only", len(streamedItems))
}
}
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiCalls++
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
"has_more": true,
"page_token": "next",
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, PaginationOptions{PageLimit: 2, PageDelay: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if apiCalls != 2 {
t.Errorf("expected 2 API calls with PageLimit=2, got %d", apiCalls)
}
if !strings.Contains(errBuf.String(), "reached page limit (2), stopping. Use --page-all --page-limit 0 to fetch all pages.") {
t.Errorf("expected page limit log, got: %s", errBuf.String())
}
// Truncation must surface in the merged output: has_more stays true so
// callers can detect loss. page_token is intentionally dropped from the
// aggregate view — to fetch more, re-run with a larger --page-limit.
resultMap, _ := result.(map[string]interface{})
data, _ := resultMap["data"].(map[string]interface{})
if hasMore, _ := data["has_more"].(bool); !hasMore {
t.Errorf("expected has_more=true when page limit truncates, got false")
}
if _, exists := data["page_token"]; exists {
t.Errorf("expected page_token to be dropped from merged output, got %v", data["page_token"])
}
}
func TestPaginateAll_NaturalEndClearsPageToken(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiCalls++
hasMore := apiCalls < 2
body := map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
"has_more": hasMore,
},
}
if hasMore {
body["data"].(map[string]interface{})["page_token"] = "next"
}
return jsonResponse(body), nil
})
ac, _ := newTestAPIClient(t, rt)
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, PaginationOptions{PageLimit: 10, PageDelay: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resultMap, _ := result.(map[string]interface{})
data, _ := resultMap["data"].(map[string]interface{})
if hasMore, _ := data["has_more"].(bool); hasMore {
t.Errorf("expected has_more=false at natural end, got true")
}
if _, exists := data["page_token"]; exists {
t.Errorf("expected page_token absent at natural end, got %v", data["page_token"])
}
}
func TestBuildApiReq_QueryParams(t *testing.T) {
ac := &APIClient{}
tests := []struct {
name string
params map[string]interface{}
want larkcore.QueryParams
}{
{
name: "scalar values",
params: map[string]interface{}{"page_size": 20, "user_id_type": "open_id"},
want: larkcore.QueryParams{
"page_size": []string{"20"},
"user_id_type": []string{"open_id"},
},
},
{
name: "[]interface{} array",
params: map[string]interface{}{"department_ids": []interface{}{"d1", "d2", "d3"}},
want: larkcore.QueryParams{
"department_ids": []string{"d1", "d2", "d3"},
},
},
{
name: "[]string array",
params: map[string]interface{}{"statuses": []string{"active", "inactive"}},
want: larkcore.QueryParams{
"statuses": []string{"active", "inactive"},
},
},
{
name: "mixed scalar and array",
params: map[string]interface{}{
"user_id_type": "open_id",
"ids": []interface{}{"id1", "id2"},
},
want: larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"ids": []string{"id1", "id2"},
},
},
{
name: "empty array",
params: map[string]interface{}{"tags": []interface{}{}},
want: larkcore.QueryParams{},
},
{
name: "nil params",
params: nil,
want: larkcore.QueryParams{},
},
{
name: "bool value",
params: map[string]interface{}{"with_bot": true},
want: larkcore.QueryParams{"with_bot": []string{"true"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiReq, _ := ac.buildApiReq(RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
Params: tt.params,
})
got := apiReq.QueryParams
// Check all expected keys exist with correct values
for k, wantVals := range tt.want {
gotVals, ok := got[k]
if !ok {
t.Errorf("missing key %q", k)
continue
}
if len(gotVals) != len(wantVals) {
t.Errorf("key %q: got %d values %v, want %d values %v", k, len(gotVals), gotVals, len(wantVals), wantVals)
continue
}
for i := range wantVals {
if gotVals[i] != wantVals[i] {
t.Errorf("key %q[%d]: got %q, want %q", k, i, gotVals[i], wantVals[i])
}
}
}
// Check no unexpected keys
for k := range got {
if _, ok := tt.want[k]; !ok {
t.Errorf("unexpected key %q with values %v", k, got[k])
}
}
})
}
}
func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, PaginationOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(errBuf.String(), "[pagination] streamed") {
t.Error("expected no streaming summary log from PaginateAll")
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
time.Sleep(25 * time.Millisecond)
_, _ = io.WriteString(w, "ok")
}))
defer srv.Close()
ac := &APIClient{
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: srv.URL,
}, core.AsBot)
if err != nil {
t.Fatalf("DoStream() error = %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(body) != "ok" {
t.Fatalf("response body = %q, want %q", string(body), "ok")
}
}
// TestDoStream_TransportFailureSplitsSubtype pins that a streaming-request
// transport failure routes through classifyNetworkSubtype rather than emitting
// a hardcoded SubtypeNetworkTransport for every cause. Concretely: a DNS
// failure must surface as SubtypeNetworkDNS so downstream agents can react
// (retry / give up / show recovery hint) without parsing the message text.
// Pre-fix, DoStream collapsed every httpClient.Do failure to NetworkTransport,
// erasing the timeout / TLS / DNS distinctions the SDK path already preserved.
func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return nil, &net.DNSError{Err: "no such host", Name: "nowhere.invalid"}
})
ac := &APIClient{
HTTP: &http.Client{Transport: rt},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/files/file_token/download",
}, core.AsBot)
if err == nil {
t.Fatal("expected DNS error from DoStream transport, got nil")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
}
if netErr.Subtype != errs.SubtypeNetworkDNS {
t.Errorf("Subtype = %q, want %q (DNS failures must not be classified as generic transport)", netErr.Subtype, errs.SubtypeNetworkDNS)
}
}
// failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{}
func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
}
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
// the missing-token path of resolveAccessToken returns the typed
// *errs.AuthenticationError{Subtype: TokenMissing}.
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.resolveAccessToken(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error when no token available, got nil")
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err)
}
if authErr.Category != errs.CategoryAuthentication {
t.Errorf("Category = %v, want %v", authErr.Category, errs.CategoryAuthentication)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
}
// needAuthTokenResolver mirrors the production credential chain: the
// missing-UAT case is constructed typed at the source (internal/auth) and
// carries the legacy *NeedAuthorizationError sentinel in its Cause chain. It
// must surface as a typed AuthenticationError and flow through resolveAccessToken
// and WrapDoAPIError unchanged (never mis-classified as NetworkError).
type needAuthTokenResolver struct {
userOpenID string
}
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, internalauth.NewNeedUserAuthorizationError(f.userOpenID)
}
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
// pins that the typed missing-UAT error from the credential chain reaches the
// caller as a typed AuthenticationError with the marker and sentinel intact.
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.resolveAccessToken(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error when credential chain signals need_user_authorization, got nil")
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
if !strings.Contains(authErr.Message, "need_user_authorization") {
t.Errorf("Message must contain the marker 'need_user_authorization' (invariant), got %q", authErr.Message)
}
// Underlying NeedAuthorizationError preserved in Cause chain so
// existing errors.As(&NeedAuthorizationError{}) consumers still match.
var needErr *internalauth.NeedAuthorizationError
if !errors.As(err, &needErr) {
t.Errorf("NeedAuthorizationError not preserved in Cause chain")
}
}
// TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError pins the
// end-to-end invariant codex caught the day this PR landed: when
// resolveAccessToken fails because no token is cached, DoSDKRequest must
// surface that as a typed *errs.AuthenticationError — not silently downgrade
// it to a network error via the SDK-failure wrap.
//
// Regression scenario: shortcut path
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
// identity with no cached token. Pre-fix this surfaced as exit 4/type=network
// and routed agents into "check your connection" instead of "log in".
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsUser)
if err == nil {
t.Fatal("expected auth error, got nil")
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("expected *errs.AuthenticationError, got %T (%v) — WrapDoAPIError must pass typed *errs.* through unchanged", err, err)
}
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
}
}
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
// SDK transport errors get the typed network classification via WrapDoAPIError.
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
// *url.Error, which the wrap classifier reaches as the transport-error
// fallback (no specific subtype matches — falls back to transport).
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return nil, io.ErrUnexpectedEOF
})
ac, _ := newTestAPIClient(t, rt)
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsBot)
if err == nil {
t.Fatal("expected error from broken transport, got nil")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
}
if netErr.Category != errs.CategoryNetwork {
t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork)
}
if netErr.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Subtype = %v, want %v", netErr.Subtype, errs.SubtypeNetworkTransport)
}
// io.ErrUnexpectedEOF round-tripping through net/http does not satisfy
// any of the specific cause checks; subtype falls back to transport.
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("ExitCodeOf = %d, want %d (network)", output.ExitCodeOf(err), output.ExitNetwork)
}
}
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
// malformed JSON response bodies: WrapJSONResponseParseError emits
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is
// "internal".
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{ malformed`)),
}, nil
})
ac, _ := newTestAPIClient(t, rt)
_, err := ac.CallAPI(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users/me",
As: "bot",
})
if err == nil {
t.Fatal("expected JSON parse error, got nil")
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err)
}
if intErr.Category != errs.CategoryInternal {
t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if intErr.Hint != rawAPIJSONHint {
t.Errorf("Hint = %q, want rawAPIJSONHint preserved", intErr.Hint)
}
if output.ExitCodeOf(err) != output.ExitInternal {
t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal)
}
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client_test
import (
"context"
"errors"
"net/http"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
factory, _, _, reg := cmdutil.TestFactory(t, config)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/drive/v1/medias/file_token/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
Headers: http.Header{
larkcore.HttpHeaderKeyLogId: []string{"202605270003"},
},
})
client, err := factory.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
_, err = client.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T %v", err, err)
}
if netErr.LogID != "202605270003" {
t.Fatalf("LogID = %q, want %q", netErr.LogID, "202605270003")
}
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"net/http"
"time"
)
// Option configures API request behavior for DoStream (and future DoSDKRequest).
type Option func(*requestConfig)
type requestConfig struct {
timeout time.Duration
headers http.Header
}
// WithTimeout sets a request-level timeout that overrides the client default.
func WithTimeout(d time.Duration) Option {
return func(c *requestConfig) {
c.timeout = d
}
}
// WithHeaders adds extra HTTP headers to the request.
func WithHeaders(h http.Header) Option {
return func(c *requestConfig) {
if c.headers == nil {
c.headers = make(http.Header)
}
for k, vs := range h {
for _, v := range vs {
c.headers.Add(k, v)
}
}
}
}
func buildConfig(opts []Option) requestConfig {
var cfg requestConfig
for _, o := range opts {
o(&cfg)
}
return cfg
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"fmt"
"io"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// PaginationOptions contains pagination control options.
type PaginationOptions struct {
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
PageDelay int // ms, default 200
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
}
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
if len(results) == 0 {
return map[string]interface{}{}
}
firstMap, ok := results[0].(map[string]interface{})
if !ok {
return map[string]interface{}{"pages": results}
}
data, ok := firstMap["data"].(map[string]interface{})
if !ok {
return map[string]interface{}{"pages": results}
}
arrayField := output.FindArrayField(data)
if arrayField == "" {
return map[string]interface{}{"pages": results}
}
var merged []interface{}
for _, r := range results {
if rm, ok := r.(map[string]interface{}); ok {
if d, ok := rm["data"].(map[string]interface{}); ok {
if items, ok := d[arrayField].([]interface{}); ok {
merged = append(merged, items...)
}
}
}
}
fmt.Fprintf(w, "[pagination] merged %d pages, %d total items\n", len(results), len(merged))
mergedData := make(map[string]interface{})
for k, v := range data {
mergedData[k] = v
}
mergedData[arrayField] = merged
// Surface the last page's real has_more so callers can detect truncation
// when --page-limit stops the loop before the API is exhausted. Page tokens
// are intentionally dropped: the merged view is an aggregate, not a resume
// cursor — to fetch more, re-run with a larger --page-limit.
lastHasMore := false
if lastMap, ok := results[len(results)-1].(map[string]interface{}); ok {
if lastData, ok := lastMap["data"].(map[string]interface{}); ok {
lastHasMore, _ = lastData["has_more"].(bool)
}
}
mergedData["has_more"] = lastHasMore
delete(mergedData, "page_token")
delete(mergedData, "next_page_token")
result := make(map[string]interface{})
for k, v := range firstMap {
result[k] = v
}
result["data"] = mergedData
return result
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
// ── Response routing ──
// ResponseOptions configures how HandleResponse routes a raw API response.
type ResponseOptions struct {
OutputPath string // --output flag; "" = auto-detect
Format output.Format // output format for JSON responses
JqExpr string // if set, apply jq filter instead of Format
Out io.Writer // stdout
ErrOut io.Writer // stderr
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
CommandPath string // raw cobra CommandPath() for content safety scanning
// Identity is forwarded to CheckError (default or caller-supplied) so the
// classifier can populate identity-aware fields (e.g. PermissionError.Identity).
// Defaults to core.AsUser when empty.
Identity core.Identity
// CheckError is called on parsed JSON results. Nil defaults to (*APIClient).CheckResponse
// with the Identity field (or AsUser when unset).
CheckError func(result interface{}, identity core.Identity) error
}
// httpStatusError classifies an HTTP error response by status when the body
// carries no usable business error: 5xx → NetworkError (server tier), 404 →
// APIError/not_found, any other 4xx → APIError/unknown. Used wherever a
// status >= 400 must not be swallowed — a non-JSON body, an unparseable body,
// or a JSON body whose business code is 0.
func httpStatusError(status int, rawBody []byte) error {
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(rawBody)), 500)
if status >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", status, body).
WithCode(status)
}
subtype := errs.SubtypeUnknown
if status == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", status, body).
WithCode(status)
}
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
// 1. If Content-Type is JSON, check for business errors first (even with --output).
// 2. If --output is set and response is not a JSON error, save to file.
// 3. If Content-Type is non-JSON and no --output, auto-save binary to file.
func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
ct := resp.Header.Get("Content-Type")
identity := opts.Identity
if identity == "" {
identity = core.AsUser
}
check := opts.CheckError
if check == nil {
// Default check routes through BuildAPIError, producing typed
// *errs.PermissionError / AuthenticationError / etc. A zero-value
// *APIClient is safe here because BuildAPIError gracefully degrades
// identity-aware fields (ConsoleURL etc.) when AppID is empty.
check = func(r interface{}, id core.Identity) error {
return (&APIClient{}).CheckResponse(r, id)
}
}
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error
// directly instead of falling through to the binary-save path.
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
// JSON responses: always check for business errors before saving.
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
// An unparseable / empty body on an HTTP error (common with a
// missing Content-Type) must be classified by status, not reported
// as an internal decode failure, matching the non-JSON branch above.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result, identity); apiErr != nil {
return apiErr
}
// CheckResponse treats business code 0 as success, so a 4xx/5xx whose
// JSON body omits a non-zero code would otherwise be served as a
// successful result. Classify by HTTP status so it is never swallowed.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
if opts.OutputPath != "" {
// File downloads keep the existing raw-response scan path because the
// saved payload is the API response body, not the success envelope.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
if opts.JqExpr != "" || opts.Format == output.FormatJSON {
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: opts.CommandPath,
Identity: string(identity),
JqExpr: opts.JqExpr,
Out: opts.Out,
ErrOut: opts.ErrOut,
})
}
// Content safety scanning for non-JSON presentation formats.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
}
// Non-JSON (binary) responses.
if opts.JqExpr != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--jq requires a JSON response (got Content-Type: %s)", ct).
WithParam("--jq")
}
if opts.OutputPath != "" {
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
// No --output: auto-save with derived filename.
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
if err != nil {
return classifySaveErr(err)
}
fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct)
output.PrintJson(opts.Out, meta)
return nil
}
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
meta, err := SaveResponse(fio, resp, path)
if err != nil {
return classifySaveErr(err)
}
output.PrintJson(w, meta)
return nil
}
// classifySaveErr routes a SaveResponse error to the right typed shape.
// Path-validation failures are caller-induced (an unsafe --output path),
// so they surface as ValidationError on --output. Mkdir / write failures
// are local I/O issues classified as InternalError with SubtypeFileIO.
func classifySaveErr(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--output")
}
return errs.NewInternalError(errs.SubtypeFileIO, "save response: %v", err).WithCause(err)
}
// ── JSON helpers ──
// IsJSONContentType reports whether the Content-Type header indicates a JSON response.
func IsJSONContentType(ct string) bool {
return strings.Contains(ct, "application/json") || strings.Contains(ct, "text/json")
}
// ParseJSONResponse decodes a raw SDK response body as JSON.
// CallAPI and HandleResponse both delegate to this function.
func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
var result interface{}
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
}
return result, nil
}
// ── File saving ──
// SaveResponse writes an API response body to the given outputPath and returns metadata.
// It delegates to FileIO.Save for path validation and atomic write; fio must not be nil.
func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
result, err := fio.Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: int64(len(resp.RawBody)),
}, bytes.NewReader(resp.RawBody))
if err != nil {
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return nil, fmt.Errorf("unsafe output path: %w", err)
case errors.As(err, &me):
return nil, fmt.Errorf("create directory: %w", err)
case errors.As(err, &we):
return nil, fmt.Errorf("cannot write file: %w", err)
default:
return nil, fmt.Errorf("cannot write file: %w", err)
}
}
resolvedPath, err := fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}
return map[string]interface{}{
"saved_path": resolvedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil
}
// ResolveFilename picks a filename from the response headers.
// Priority: Content-Disposition filename > Content-Type extension > "download.bin".
func ResolveFilename(resp *larkcore.ApiResp) string {
if name := larkcore.FileNameByHeader(resp.Header); name != "" {
return name
}
return "download" + mimeToExt(resp.Header.Get("Content-Type"))
}
// mimeToExt maps a Content-Type to a file extension (with leading dot).
func mimeToExt(ct string) string {
if ct == "" {
return ".bin"
}
mediaType, _, _ := mime.ParseMediaType(ct)
switch mediaType {
case "application/pdf":
return ".pdf"
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "text/plain":
return ".txt"
case "text/csv":
return ".csv"
case "text/html":
return ".html"
case "application/zip":
return ".zip"
case "application/xml", "text/xml":
return ".xml"
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
return ".xlsx"
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return ".docx"
case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
return ".pptx"
default:
return ".bin"
}
}
+519
View File
@@ -0,0 +1,519 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
return newApiRespWithStatus(200, body, headers)
}
func newApiRespWithStatus(status int, body []byte, headers map[string]string) *larkcore.ApiResp {
h := http.Header{}
for k, v := range headers {
h.Set(k, v)
}
return &larkcore.ApiResp{
StatusCode: status,
Header: h,
RawBody: body,
}
}
func TestIsJSONContentType_Extended(t *testing.T) {
tests := []struct {
ct string
want bool
}{
{"application/json", true},
{"application/json; charset=utf-8", true},
{"text/json", true},
{"application/octet-stream", false},
{"", false},
}
for _, tt := range tests {
if got := IsJSONContentType(tt.ct); got != tt.want {
t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want)
}
}
}
func TestParseJSONResponse(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"123"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
result, err := ParseJSONResponse(resp)
if err != nil {
t.Fatalf("ParseJSONResponse failed: %v", err)
}
m, ok := result.(map[string]interface{})
if !ok {
t.Fatal("expected map result")
}
if m["msg"] != "ok" {
t.Errorf("expected msg=ok, got %v", m["msg"])
}
}
func TestParseJSONResponse_Invalid(t *testing.T) {
resp := newApiResp([]byte(`not json`), map[string]string{"Content-Type": "application/json"})
_, err := ParseJSONResponse(resp)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
_, err := ParseJSONResponse(resp)
if err == nil {
t.Fatal("expected error for empty body")
}
if !errors.Is(err, io.EOF) {
t.Fatalf("expected wrapped io.EOF, got %v", err)
}
}
func TestResolveFilename(t *testing.T) {
tests := []struct {
name string
headers map[string]string
want string
}{
{
"from content-type pdf",
map[string]string{"Content-Type": "application/pdf"},
"download.pdf",
},
{
"from content-type png",
map[string]string{"Content-Type": "image/png"},
"download.png",
},
{
"unknown type",
map[string]string{"Content-Type": "application/octet-stream"},
"download.bin",
},
{
"empty content-type",
map[string]string{},
"download.bin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := newApiResp([]byte("data"), tt.headers)
got := ResolveFilename(resp)
if got != tt.want {
t.Errorf("ResolveFilename() = %q, want %q", got, tt.want)
}
})
}
}
func TestMimeToExt_Extended(t *testing.T) {
tests := []struct {
ct string
want string
}{
{"application/pdf", ".pdf"},
{"image/png", ".png"},
{"image/jpeg", ".jpg"},
{"image/gif", ".gif"},
{"text/plain", ".txt"},
{"text/csv", ".csv"},
{"text/html", ".html"},
{"application/zip", ".zip"},
{"application/xml", ".xml"},
{"text/xml", ".xml"},
{"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"},
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"},
{"application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx"},
{"application/octet-stream", ".bin"},
{"", ".bin"},
}
for _, tt := range tests {
if got := mimeToExt(tt.ct); got != tt.want {
t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want)
}
}
}
func TestSaveResponse(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
body := []byte("hello binary data")
resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
if meta["size_bytes"] != int64(len(body)) {
t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"])
}
savedPath, _ := meta["saved_path"].(string)
data, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if !bytes.Equal(data, body) {
t.Errorf("saved content mismatch")
}
}
func TestSaveResponse_CreatesDir(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin"))
if err != nil {
t.Fatalf("SaveResponse with nested dir failed: %v", err)
}
savedPath, _ := meta["saved_path"].(string)
if _, err := os.Stat(savedPath); err != nil {
t.Errorf("expected file to exist at %s", savedPath)
}
}
func TestHandleResponse_JSON(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Identity: core.AsBot,
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if got["ok"] != true {
t.Fatalf("ok = %v, want true; output: %s", got["ok"], out.String())
}
if got["identity"] != "bot" {
t.Fatalf("identity = %v, want bot; output: %s", got["identity"], out.String())
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code field: %s", out.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %T, want object; output: %s", got["data"], out.String())
}
if data["id"] != "1" {
t.Fatalf("data.id = %v, want 1; output: %s", data["id"], out.String())
}
}
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Identity: core.AsBot,
JqExpr: ".data.id",
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
}
if strings.TrimSpace(out.String()) != "1" {
t.Fatalf("jq output = %q, want %q", out.String(), "1")
}
}
func TestHandleResponse_JSONWithError(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err == nil {
t.Error("expected error for non-zero code")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if strings.Contains(out.String(), `"ok": true`) || strings.Contains(out.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", out.String())
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse binary failed: %v", err)
}
if !bytes.Contains(errOut.Bytes(), []byte("binary response detected")) {
t.Errorf("expected binary detection message, got: %s", errOut.String())
}
}
func TestHandleResponse_BinaryWithOutput(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
OutputPath: "out.png",
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse with output path failed: %v", err)
}
data, _ := os.ReadFile("out.png")
if string(data) != "PNG DATA" {
t.Errorf("expected saved PNG DATA, got: %s", data)
}
}
func TestHandleResponse_NonJSONError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 text/plain")
}
got := err.Error()
if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") {
t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
func TestHandleResponse_NonJSONError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), map[string]string{"Content-Type": "text/html"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 text/html")
}
got := err.Error()
if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") {
t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got)
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Errorf("expected *errs.NetworkError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
}
}
// TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed pins that an HTTP
// status error whose JSON body omits a non-zero business code (e.g. 400 +
// {"code":0,...}) still surfaces a typed error. CheckResponse treats code 0 as
// success, so without the HTTP-status fallback a 4xx would be served as a
// successful result and exit 0.
func TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed(t *testing.T) {
resp := newApiRespWithStatus(400, []byte(`{"code":0,"msg":"bad request"}`),
map[string]string{"Content-Type": "application/json"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got out=%q err=nil", out.String())
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("expected 'HTTP 400' in error, got: %s", err.Error())
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_404 pins that a 404 with an empty body
// and no Content-Type header — which falls into the JSON branch and fails to
// parse — is classified by HTTP status (api/not_found), not reported as an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte(""), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 with empty body and no Content-Type")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if apiErr != nil && apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want not_found", apiErr.Subtype)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_502 pins that a 5xx with a non-JSON
// body and no Content-Type is classified as a NetworkError by status, not an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 with non-JSON body and no Content-Type")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Errorf("expected *errs.NetworkError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
}
}
func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err != nil {
t.Fatalf("expected no error for 200 text/plain, got: %v", err)
}
if !strings.Contains(errOut.String(), "binary response detected") {
t.Errorf("expected binary detection message, got: %s", errOut.String())
}
}
func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
JqExpr: ".data",
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected error when --jq is used with non-JSON response")
}
if !strings.Contains(err.Error(), "--jq requires a JSON response") {
t.Errorf("expected '--jq requires a JSON response' error, got: %v", err)
}
}
func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt")
if err == nil {
t.Fatal("expected error for path traversal")
}
if !strings.Contains(err.Error(), "unsafe output path") {
t.Errorf("expected 'unsafe output path' wrapper, got: %v", err)
}
}
func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
if err == nil {
t.Fatal("expected error for absolute path")
}
}
func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"})
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
savedPath, _ := meta["saved_path"].(string)
if !filepath.IsAbs(savedPath) {
t.Errorf("saved_path should be absolute, got %q", savedPath)
}
}
+233
View File
@@ -0,0 +1,233 @@
// 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, the hook selector, and help rendering consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need, plus the
// affordance ref (service, method id) that lets service-method and shortcut
// help share one usage-guidance lookup path.
//
// 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"
)
// Source identifies how a command entered the repository-owned command tree.
type Source string
const (
SourceBuiltin Source = "builtin"
SourceShortcut Source = "shortcut"
SourceService Source = "service"
)
const (
// domainAnnotationKey is the cobra Annotation key for the business domain.
// Kept distinct from cmdutil.* keys so this package can evolve without
// disturbing existing readers.
domainAnnotationKey = "cmdmeta.domain"
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
// affordance{Service,Method}Key locate the command's usage-guidance overlay
// entry (see internal/affordance). Both service-method commands and
// +-prefixed shortcuts set these so help rendering shares one lookup path.
affordanceServiceKey = "cmdmeta.affordance.service"
affordanceMethodKey = "cmdmeta.affordance.method"
)
// 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
}
// SetSource stores the command source on a single command. The generated flag
// is written explicitly so child commands can opt out of inherited service
// metadata.
func SetSource(cmd *cobra.Command, source Source, generated bool) {
if source == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[sourceAnnotationKey] = string(source)
if generated {
cmd.Annotations[generatedAnnotationKey] = "true"
} else {
cmd.Annotations[generatedAnnotationKey] = "false"
}
}
// SetAffordanceRef records which affordance overlay entry (service, method id)
// a command maps to, so help rendering can look up its usage guidance. Stored
// on the command itself (no inheritance): each method / shortcut owns its ref.
// A no-op if either coordinate is empty.
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
if service == "" || method == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[affordanceServiceKey] = service
cmd.Annotations[affordanceMethodKey] = method
}
// AffordanceRef returns the command's own affordance overlay coordinates.
// ok is false when the command carries no ref.
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
service = cmd.Annotations[affordanceServiceKey]
method = cmd.Annotations[affordanceMethodKey]
if service == "" || method == "" {
return "", "", false
}
return service, method, true
}
// 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 ""
}
// SourceOf returns the nearest-ancestor command source.
func SourceOf(cmd *cobra.Command) (Source, bool) {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v := c.Annotations[sourceAnnotationKey]; v != "" {
return Source(v), true
}
}
return "", false
}
// Generated returns the nearest generated annotation. An explicit false on a
// child command stops inheritance from a generated parent.
func Generated(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v, ok := c.Annotations[generatedAnnotationKey]; ok {
return v == "true"
}
}
return false
}
// 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
}
+159
View File
@@ -0,0 +1,159 @@
// 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)
}
}
func TestSourceGenerated_childFalseStopsParentGeneratedInheritance(t *testing.T) {
parent := &cobra.Command{Use: "docs"}
child := &cobra.Command{Use: "+fetch"}
parent.AddCommand(child)
cmdmeta.SetSource(parent, cmdmeta.SourceService, true)
cmdmeta.SetSource(child, cmdmeta.SourceShortcut, false)
if source, ok := cmdmeta.SourceOf(child); !ok || source != cmdmeta.SourceShortcut {
t.Fatalf("SourceOf(child) = (%q,%v), want (shortcut,true)", source, ok)
}
if cmdmeta.Generated(child) {
t.Fatal("Generated(child) = true, want false")
}
}
+93
View File
@@ -0,0 +1,93 @@
// 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.
//
// Rules is the full set the winning source contributed (one rule for the
// common single-rule case, several when a plugin or yaml declares scoped
// grants). nil/empty means "no rule applied".
type ActivePolicy struct {
Rules []*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, the Rules slice, and
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
// value types so the struct copy already disjoints them.
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
if in == nil {
return nil
}
cp := *in
if in.Rules != nil {
cp.Rules = make([]*platform.Rule, len(in.Rules))
for i, r := range in.Rules {
if r == nil {
continue
}
rule := *r
rule.Allow = append([]string(nil), r.Allow...)
rule.Deny = append([]string(nil), r.Deny...)
rule.Identities = append([]platform.Identity(nil), r.Identities...)
cp.Rules[i] = &rule
}
}
return &cp
}
// 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
}
+356
View File
@@ -0,0 +1,356 @@
// 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/errs"
"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 returns a typed *errs.ValidationError that exposes BOTH paths
// consumers rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
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: typed-envelope view. The denial is a failed_precondition
// ValidationError so cmd/root.go renders the structured envelope and
// the process exits 2 (ExitValidation).
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
}
// The policy metadata is folded into the Hint text: reason_code,
// policy_source, and rule_name must all be discoverable there.
if !strings.Contains(ve.Hint, "write_not_allowed") {
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
}
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
// is preserved as the Cause so errors.As still reaches it.
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)
}
}
// 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 `<group> --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 `<group>
// --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")
}
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
)
// 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 a typed
// *errs.ValidationError so
// cmd/root.go's envelope writer
// emits structured JSON; 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,
}
}
// BuildDenialError is the default typed error for user-layer denials:
// Message comes from CommandDeniedError.Error(); the policy layer, source,
// rule name, and reason code are folded into the Hint. The
// *platform.CommandDeniedError is preserved as the Cause so errors.As
// works for in-process consumers.
func BuildDenialError(path string, d Denial) *errs.ValidationError {
cd := CommandDeniedFromDenial(path, d)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", cd.Error()).
WithHint("denied by %s policy (source %s, rule %q, reason_code %s); adjust the policy configuration to allow this command",
cd.Layer, cd.PolicySource, cd.RuleName, cd.ReasonCode).
WithCause(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 {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// 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
}
+130
View File
@@ -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
})
}
+98
View File
@@ -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])
}
}
}
+29
View File
@@ -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]
}
+86
View File
@@ -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)
}
}
}
+478
View File
@@ -0,0 +1,478 @@
// 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 typed validation errors of the same shape
// (failed_precondition, *platform.CommandDeniedError preserved as Cause) so
// external agents see a uniform envelope regardless of which layer rejected
// the call.
package cmdpolicy
import (
"fmt"
"strings"
"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 set of Rules against the command tree with OR
// semantics: a command is allowed when it satisfies every axis of AT
// LEAST ONE rule. It is stateless except for the Rule snapshot it was
// constructed with.
type Engine struct {
rules []*platform.Rule
}
// New returns an Engine bound to a single Rule. A nil Rule means "no
// user-layer restriction" -- EvaluateOne always returns Allowed=true.
// It is the ergonomic single-rule constructor, kept so existing callers
// (and the single-rule decision path) stay byte-for-byte unchanged.
func New(rule *platform.Rule) *Engine {
if rule == nil {
return &Engine{}
}
return &Engine{rules: []*platform.Rule{rule}}
}
// NewSet returns an Engine bound to a set of Rules evaluated with OR
// semantics. An empty/nil slice means "no user-layer restriction". nil
// entries are dropped so callers may pass a slice with gaps without a
// separate filter step.
//
// With exactly one rule the behaviour is identical to New(rule): the
// rejection Decision is returned verbatim. With multiple rules a command
// rejected by all of them gets the aggregate reason_code
// "no_matching_rule" (see mergeDenials).
func NewSet(rules []*platform.Rule) *Engine {
cleaned := make([]*platform.Rule, 0, len(rules))
for _, r := range rules {
if r != nil {
cleaned = append(cleaned, r)
}
}
if len(cleaned) == 0 {
return &Engine{}
}
return &Engine{rules: cleaned}
}
// EvaluateAll walks the command tree and evaluates every **runnable**
// 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 `<group> --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. With multiple rules the
// decision is the OR over per-rule evaluations: the command is allowed as
// soon as one rule grants it; if every rule rejects it, the rejections are
// merged (see mergeDenials).
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
if len(e.rules) == 0 {
return Decision{Allowed: true}
}
path := CanonicalPath(cmd)
if IsDiagnosticPath(path) {
return Decision{Allowed: true}
}
// risk_invalid is a property of the COMMAND's own annotation (the
// annotation exists but is a typo / not in the closed taxonomy
// read / write / high-risk-write). It is independent of any Rule and
// is always fail-closed regardless of AllowUnannotated -- a typo is a
// code bug, not a migration phase. So it is checked once up front,
// before the per-rule OR loop, and short-circuits to deny.
//
// The "absent" case (no risk_level annotation at all) is per-rule:
// each rule's AllowUnannotated decides, so it lives inside evalRule.
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
cmdRisk := platform.Risk(cmdRiskStr)
var (
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)),
}
}
}
// OR across rules: the first rule that fully grants the command wins.
denials := make([]Decision, 0, len(e.rules))
for _, r := range e.rules {
d := evalRule(r, path, cmd, hasRisk, cmdRisk, cmdRank, cmdRankOk)
if d.Allowed {
return Decision{Allowed: true}
}
denials = append(denials, d)
}
return mergeDenials(e.rules, denials)
}
// evalRule applies one Rule's four-axis AND filter to a command whose
// risk annotation has already been parsed by EvaluateOne (risk_invalid is
// handled there). cmdRankOk is false only when the command is unannotated
// (hasRisk=false); a present-but-invalid risk never reaches here. Returns
// Allowed=true only when the command clears every axis of this rule.
func evalRule(r *platform.Rule, path string, cmd *cobra.Command, hasRisk bool, cmdRisk platform.Risk, cmdRank int, cmdRankOk bool) Decision {
// Unannotated gate: fail-closed unless THIS rule opts out. A command
// with no risk_level annotation can still be granted by a rule that
// sets AllowUnannotated=true (gradual-adoption opt-in); other rules in
// the set reject it here and the OR moves on.
if !hasRisk && !r.AllowUnannotated {
return Decision{
Allowed: false,
ReasonCode: "risk_not_annotated",
Reason: "command has no risk_level annotation; rule denies unannotated commands",
}
}
// Axis 1: Deny has priority. Note OR semantics scope a rule's Deny to
// that rule only -- it cannot veto another rule's Allow. A command to
// block everywhere must be denied (or simply not allowed) by every rule.
if matched, ok := firstMatch(r.Deny, path); ok {
return Decision{
Allowed: false,
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}
}
// mergeDenials collapses the per-rule rejections into a single Decision
// for a command that no rule granted. denials is parallel to rules (same
// order, one entry per rule, all Allowed=false).
//
// With exactly one rule the original rejection is returned verbatim, so
// single-rule envelopes are byte-for-byte identical to the pre-multi-rule
// behaviour (reason_code / reason unchanged). With multiple rules the
// rejection is the aggregate reason_code "no_matching_rule"; its Reason
// enumerates each rule's own rejection for debugging.
func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
if len(denials) == 1 {
return denials[0]
}
parts := make([]string, len(denials))
for i, d := range denials {
name := rules[i].Name
if name == "" {
name = fmt.Sprintf("#%d", i)
}
parts[i] = fmt.Sprintf("%s: %s", name, d.ReasonCode)
}
return Decision{
Allowed: false,
ReasonCode: "no_matching_rule",
Reason: fmt.Sprintf("no rule grants this command (%s)", strings.Join(parts, "; ")),
}
}
// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed
// by canonical path. It performs the parent-group aggregation defined in
// the tech doc: a non-runnable parent whose every runnable descendant is
// 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)
}
}
+592
View File
@@ -0,0 +1,592 @@
// 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")
}
}
// --- Multi-rule (OR) semantics ---
// Two scoped rules (docs read-only, im writable) are OR-combined: a
// command is allowed when it satisfies ANY rule. This is the headline
// multi-rule use case -- different command groups need different risk
// ceilings within one policy.
func TestEvaluate_multiRuleOR(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
})
got := e.EvaluateAll(root)
// docs/+fetch (read) clears docs-ro.
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should be allowed by docs-ro")
}
// im/+send (write) clears im-rw even though docs-ro rejects it.
if !got["im/+send"].Allowed {
t.Errorf("im/+send (write) should be allowed by im-rw")
}
// docs/+update (write) exceeds docs-ro's read ceiling AND is outside
// im-rw's allow list -> rejected by both -> no_matching_rule.
if got["docs/+update"].Allowed {
t.Fatalf("docs/+update should be denied: read-only in docs, not allowed in im")
}
if rc := got["docs/+update"].ReasonCode; rc != "no_matching_rule" {
t.Errorf("docs/+update ReasonCode = %q, want no_matching_rule", rc)
}
}
// Identity can differ per rule: docs limited to user, im open to bot.
// This is the second half of the requirement -- some commands restrict
// identity, others allow the bot identity.
func TestEvaluate_multiRulePerRuleIdentity(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Name: "docs-user", Allow: []string{"docs/**"}, MaxRisk: "write", Identities: []platform.Identity{"user"}},
{Name: "im-bot", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"bot"}},
})
got := e.EvaluateAll(root)
// docs/+update identities=[user] -> docs-user grants.
if !got["docs/+update"].Allowed {
t.Errorf("docs/+update (user) should be allowed by docs-user")
}
// im/+send identities=[bot] -> im-bot grants.
if !got["im/+send"].Allowed {
t.Errorf("im/+send (bot) should be allowed by im-bot")
}
// docs/+delete-doc is high-risk-write -> exceeds both ceilings -> denied.
if got["docs/+delete-doc"].Allowed {
t.Errorf("docs/+delete-doc (high-risk-write) should be denied by both rules")
}
}
// NewSet with a single rule must behave exactly like New: the per-rule
// rejection (not the aggregate no_matching_rule) is preserved so the
// single-rule envelope is unchanged.
func TestEvaluate_newSetSingleRuleKeepsReason(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Allow: []string{"docs/**"}},
})
got := e.EvaluateAll(root)
if got["im/+send"].Allowed {
t.Fatalf("im/+send should be denied by docs-only rule")
}
if rc := got["im/+send"].ReasonCode; rc != "domain_not_allowed" {
t.Errorf("single-rule reason must be preserved verbatim, got %q want domain_not_allowed", rc)
}
}
// NewSet drops nil entries; an all-nil/empty set means "no restriction".
func TestNewSet_emptyAndNilMeansNoRestriction(t *testing.T) {
root := buildTree()
for _, rules := range [][]*platform.Rule{nil, {}, {nil}} {
got := cmdpolicy.NewSet(rules).EvaluateAll(root)
for path, d := range got {
if !d.Allowed {
t.Fatalf("empty/nil rule set must allow all, got deny for %s", path)
}
}
}
}
// Apply must install denyStubs only on Layer="policy" entries. A
// "strict_mode" denial in the same map must be left for
// applyStrictModeDenials in cmd/.
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
}
+39
View File
@@ -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
}
+117
View File
@@ -0,0 +1,117 @@
// 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
YAMLRules []*platform.Rule
YAMLPath string
}
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one plugin may own the policy")
// Resolve picks by precedence: plugin > yaml > none, returning the full
// rule set the winning source contributes. Pure function; load yaml via
// LoadYAMLPolicy first. Every returned rule is validated.
//
// Multi-rule semantics (single owner): one plugin may contribute several
// rules (each a scoped grant, OR-combined by the engine), but two or more
// DISTINCT plugins contributing rules is still a configuration error --
// the resolver aborts so independent plugins cannot silently widen each
// other's policy. yaml may likewise carry several rules under "rules:".
func Resolve(s Sources) ([]*platform.Rule, ResolveSource, error) {
owners := distinctOwners(s.PluginRules)
if len(owners) > 1 {
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, owners)
}
if len(s.PluginRules) > 0 {
rules := make([]*platform.Rule, 0, len(s.PluginRules))
for _, pr := range s.PluginRules {
if err := ValidateRule(pr.Rule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
}
rules = append(rules, pr.Rule)
}
return rules, ResolveSource{Kind: SourcePlugin, Name: owners[0]}, nil
}
if len(s.YAMLRules) > 0 {
for _, r := range s.YAMLRules {
if err := ValidateRule(r); err != nil {
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
}
}
return s.YAMLRules, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
}
return nil, ResolveSource{Kind: SourceNone}, nil
}
// distinctOwners returns the unique plugin names contributing a rule, in
// first-seen order. A single plugin contributing N rules collapses to one
// owner; that is the case the single-owner check below permits.
func distinctOwners(prs []PluginRule) []string {
seen := map[string]bool{}
owners := make([]string, 0, len(prs))
for _, pr := range prs {
if !seen[pr.PluginName] {
seen[pr.PluginName] = true
owners = append(owners, pr.PluginName)
}
}
return owners
}
// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent,
// so callers can pass the result straight into Sources.YAMLRules. A
// present file yields one or more rules (see yaml.Parse).
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)
}
rules, err := pyaml.Parse(data)
if err != nil {
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
}
return rules, nil
}
+162
View File
@@ -0,0 +1,162 @@
// 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 len(got) != 1 || got[0] != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
t.Fatalf("Resolve = (%v, %+v)", got, src)
}
}
// A single plugin may contribute several rules (each a scoped grant). They
// are all returned, in registration order, under one plugin source.
func TestResolve_singlePluginMultipleRules(t *testing.T) {
r1 := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"}
r2 := &platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{
{PluginName: "secaudit", Rule: r1},
{PluginName: "secaudit", Rule: r2},
},
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 2 || got[0] != r1 || got[1] != r2 {
t.Fatalf("expected both rules in order, got %v", got)
}
if src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
t.Fatalf("source = %+v, want plugin:secaudit", src)
}
}
func TestResolve_pluginShadowsYaml(t *testing.T) {
pluginRule := &platform.Rule{Name: "from-plugin"}
yamlRule := &platform.Rule{Name: "from-yaml"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
YAMLRules: []*platform.Rule{yamlRule},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0].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{
YAMLRules: []*platform.Rule{yamlRule},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0].Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
}
if src.Name != "/some/policy.yml" {
t.Errorf("yaml source Name should carry path, got %q", src.Name)
}
}
// yaml may also carry several rules under "rules:"; all are returned.
func TestResolve_yamlMultipleRules(t *testing.T) {
r1 := &platform.Rule{Name: "a", MaxRisk: "read"}
r2 := &platform.Rule{Name: "b", MaxRisk: "write"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
YAMLRules: []*platform.Rule{r1, r2},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 2 || src.Kind != cmdpolicy.SourceYAML {
t.Fatalf("expected both yaml rules, got %v / %+v", got, src)
}
}
func TestResolve_emptyEverythingIsNone(t *testing.T) {
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 0 || src.Kind != cmdpolicy.SourceNone {
t.Fatalf("expected (empty, SourceNone), got (%v, %+v)", got, src)
}
}
// Two DISTINCT plugins both contributing a Rule must produce the typed
// error so the bootstrap pipeline aborts (single-owner invariant): one
// plugin cannot silently widen another plugin's policy.
func TestResolve_multipleRestrictPluginsIsError(t *testing.T) {
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
{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.YAMLRules without special-
// casing not-exist.
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
rules, err := cmdpolicy.LoadYAMLPolicy(missing)
if err != nil {
t.Fatalf("missing yaml should not error, got %v", err)
}
if rules != nil {
t.Fatalf("missing yaml should return nil rules, got %+v", rules)
}
}
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
rules, err := cmdpolicy.LoadYAMLPolicy("")
if err != nil {
t.Fatalf("empty path should not error, got %v", err)
}
if rules != nil {
t.Fatalf("empty path should return nil rules, got %+v", rules)
}
}
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)
}
rules, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
if err != nil {
t.Fatalf("LoadYAMLPolicy err: %v", err)
}
if len(rules) != 1 || rules[0].Name != "from-yaml" {
t.Fatalf("expected one rule with name=from-yaml, got %+v", rules)
}
}
+94
View File
@@ -0,0 +1,94 @@
// 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/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
)
// 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 ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected denial *errs.ValidationError, got %T %v", err, err)
}
// The policy source is folded into the Hint as "yaml" -- the bare
// kind, never the absolute path.
if !strings.Contains(ve.Hint, "source yaml") {
t.Errorf("hint must carry policy_source %q (no path leak), got %q", "yaml", ve.Hint)
}
// rule_name carries the disambiguating identifier.
if !strings.Contains(ve.Hint, "my-readonly-rule") {
t.Errorf("hint must carry rule_name my-readonly-rule, got %q", ve.Hint)
}
// Direct privacy probe: the absolute home path must not appear
// anywhere in the user-facing message OR hint text.
if strings.Contains(ve.Message, "/Users/alice") {
t.Errorf("error message must not leak '/Users/alice', got %q", ve.Message)
}
if strings.Contains(ve.Hint, "/Users/alice") {
t.Errorf("error hint must not leak '/Users/alice', got %q", ve.Hint)
}
}
// 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 ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// The plugin name IS surfaced (in-binary, part of the contract): it
// must appear in the Hint so an integrator debugging a denial knows
// which plugin fired.
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
}
}
+163
View File
@@ -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)
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
// 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 := suggest.Levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := suggest.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)
}
+31
View File
@@ -0,0 +1,31 @@
// 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)
}
}
}
+75
View File
@@ -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
}
+97
View File
@@ -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)
}
}
+24
View File
@@ -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
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package yaml parses one or more Rules from yaml bytes. It is kept
// separate from the public extension/platform package so that platform
// stays free of yaml library dependencies -- plugins constructing a Rule
// in Go code never import yaml, only the file loader does.
//
// This package does **structural** parsing only (yaml syntax + unknown-field
// rejection). Semantic validation (valid MaxRisk enum, valid identity
// 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"
)
// ruleSchema is the internal yaml-tagged shape of one rule. Mirrors
// platform.Rule but lives here so the public Rule has no yaml tag baggage.
type ruleSchema struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Allow []string `yaml:"allow,omitempty"`
Deny []string `yaml:"deny,omitempty"`
MaxRisk string `yaml:"max_risk,omitempty"`
Identities []string `yaml:"identities,omitempty"`
AllowUnannotated bool `yaml:"allow_unannotated,omitempty"`
}
// fileSchema is the top-level document shape. Two mutually-exclusive
// layouts are accepted:
//
// - a single rule written with flat top-level fields (the historical
// layout; the inlined ruleSchema), or
// - a "rules:" list of rule objects (multi-rule layout).
//
// Mixing the two (flat fields AND a rules: list in the same file) is a
// configuration error -- Parse rejects it rather than guessing intent.
//
// Rules is a pointer so Parse can tell "rules: key absent" (nil) apart
// from "rules: present but empty" (non-nil, len 0). The latter is a
// foot-gun -- a config generator that renders an empty list would
// otherwise yield a single all-zero Rule that lets every annotated
// command through -- so Parse rejects it outright.
type fileSchema struct {
ruleSchema `yaml:",inline"`
Rules *[]ruleSchema `yaml:"rules,omitempty"`
}
// isZero reports whether every field is its zero value. Used to detect
// the flat-fields-plus-rules: mixing error.
func (s ruleSchema) isZero() bool {
return s.Name == "" && s.Description == "" &&
len(s.Allow) == 0 && len(s.Deny) == 0 &&
s.MaxRisk == "" && len(s.Identities) == 0 && !s.AllowUnannotated
}
func (s ruleSchema) toRule() *platform.Rule {
// Leave Identities nil when absent (omitempty-style), matching how the
// Allow/Deny slices arrive nil from yaml. A zero-length but non-nil
// slice is behaviourally identical to the engine but trips
// reflect.DeepEqual in tests and reads as "explicitly empty".
var idents []platform.Identity
if len(s.Identities) > 0 {
idents = make([]platform.Identity, len(s.Identities))
for i, id := range s.Identities {
idents[i] = platform.Identity(id)
}
}
return &platform.Rule{
Name: s.Name,
Description: s.Description,
Allow: s.Allow,
Deny: s.Deny,
MaxRisk: platform.Risk(s.MaxRisk),
Identities: idents,
AllowUnannotated: s.AllowUnannotated,
}
}
// Parse decodes yaml bytes into one or more *platform.Rule. Unknown fields
// are rejected so an old binary cannot silently ignore new schema additions
// (forward-compat safeguard).
//
// The result always has at least one element: a flat-fields document
// yields a single rule (possibly an all-zero "no restriction" rule), and a
// "rules:" list yields one rule per entry.
//
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
// the caller's responsibility -- run each result through
// internal/cmdpolicy.ValidateRule before handing it to the engine.
func Parse(data []byte) ([]*platform.Rule, error) {
var s fileSchema
dec := gopkgyaml.NewDecoder(bytesReader(data))
dec.KnownFields(true)
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
// Reject multi-document input: yaml.v3 only decodes one document
// per call, so a stray "---" followed by another document would
// silently drop the trailing rule.
var extra fileSchema
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed")
}
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
if s.Rules != nil {
if len(*s.Rules) == 0 {
return nil, fmt.Errorf("parse policy yaml: 'rules:' is present but empty; remove the key, or list at least one rule")
}
if !s.ruleSchema.isZero() {
return nil, fmt.Errorf("parse policy yaml: top-level rule fields cannot be combined with a 'rules:' list; move every rule under 'rules:'")
}
out := make([]*platform.Rule, 0, len(*s.Rules))
for _, rs := range *s.Rules {
out = append(out, rs.toRule())
}
return out, nil
}
// Backward-compatible single top-level rule (flat fields).
return []*platform.Rule{s.ruleSchema.toRule()}, nil
}
+182
View File
@@ -0,0 +1,182 @@
// 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
`)
rules, 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"},
}
// A flat top-level rule yields exactly one element (backward compat).
if !reflect.DeepEqual(rules, []*platform.Rule{want}) {
t.Fatalf("rules = %+v, want single %+v", rules, want)
}
}
// A "rules:" list yields one platform.Rule per entry, in order. This is
// the multi-rule layout: each rule is a scoped grant the engine
// OR-combines.
func TestParse_rulesList(t *testing.T) {
data := []byte(`
rules:
- name: docs-ro
allow: [docs/**]
max_risk: read
- name: im-rw
allow: [im/**]
max_risk: write
identities: [user, bot]
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
want := []*platform.Rule{
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"user", "bot"}},
}
if !reflect.DeepEqual(rules, want) {
t.Fatalf("rules = %+v, want %+v", rules, want)
}
}
// A "rules:" key that is present but empty is a foot-gun: an empty list
// would otherwise fall through to a single all-zero Rule that allows
// every annotated command ("looks like a policy, enforces almost
// nothing"). Parse must reject it outright instead.
func TestParse_rejectsEmptyRulesList(t *testing.T) {
if _, err := pyaml.Parse([]byte("rules: []\n")); err == nil {
t.Fatalf("Parse should reject a present-but-empty 'rules:' list")
}
}
// Mixing top-level flat rule fields with a rules: list is ambiguous and
// must be rejected rather than silently picking one.
func TestParse_rejectsFlatPlusRulesMix(t *testing.T) {
data := []byte(`
name: top-level
rules:
- name: nested
`)
if _, err := pyaml.Parse(data); err == nil {
t.Fatalf("Parse should reject mixing top-level fields with a rules: list")
}
}
// 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
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if !rules[0].AllowUnannotated {
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
}
if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" {
t.Errorf("other fields lost: %+v", rules[0])
}
}
// 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
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if rules[0].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) {
rules, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
if err != nil {
t.Fatalf("structural parse should succeed, got %v", err)
}
if rules[0].MaxRisk != "nuclear" {
t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].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")
}
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"strings"
"github.com/spf13/cobra"
)
const skipAuthCheckKey = "skipAuthCheck"
const annotationSupportedIdentities = "lark:supportedIdentities"
// SetSupportedIdentities marks which identities a command supports.
func SetSupportedIdentities(cmd *cobra.Command, identities []string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[annotationSupportedIdentities] = strings.Join(identities, ",")
}
// GetSupportedIdentities returns the declared identities, or nil if not declared.
func GetSupportedIdentities(cmd *cobra.Command) []string {
v, ok := cmd.Annotations[annotationSupportedIdentities]
if !ok || v == "" {
return nil
}
return strings.Split(v, ",")
}
// DisableAuthCheck marks a command (and all its children) as not requiring auth.
func DisableAuthCheck(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[skipAuthCheckKey] = "true"
}
// IsAuthCheckDisabled returns true if the command or any ancestor has auth check disabled.
func IsAuthCheckDisabled(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations != nil && c.Annotations[skipAuthCheckKey] == "true" {
return true
}
}
return false
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestDisableAuthCheck(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if IsAuthCheckDisabled(cmd) {
t.Error("expected auth check to be enabled by default")
}
DisableAuthCheck(cmd)
if !IsAuthCheckDisabled(cmd) {
t.Error("expected auth check to be disabled after DisableAuthCheck")
}
}
func TestIsAuthCheckDisabled_Inheritance(t *testing.T) {
parent := &cobra.Command{Use: "parent"}
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
if IsAuthCheckDisabled(child) {
t.Error("expected child auth check enabled before parent annotation")
}
DisableAuthCheck(parent)
if !IsAuthCheckDisabled(child) {
t.Error("expected child to inherit disabled auth check from parent")
}
}
func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) {
parent := &cobra.Command{Use: "parent"}
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
DisableAuthCheck(child)
if IsAuthCheckDisabled(parent) {
t.Error("parent should not inherit disabled auth check from child")
}
if !IsAuthCheckDisabled(child) {
t.Error("child should have disabled auth check")
}
}
func TestSetGetSupportedIdentities(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if got := GetSupportedIdentities(cmd); got != nil {
t.Errorf("expected nil, got %v", got)
}
SetSupportedIdentities(cmd, []string{"user", "bot"})
got := GetSupportedIdentities(cmd)
if len(got) != 2 || got[0] != "user" || got[1] != "bot" {
t.Errorf("expected [user bot], got %v", got)
}
}
func TestSetSupportedIdentities_OverwriteExisting(t *testing.T) {
cmd := &cobra.Command{Use: "test", Annotations: map[string]string{"other": "val"}}
SetSupportedIdentities(cmd, []string{"bot"})
if cmd.Annotations["other"] != "val" {
t.Error("existing annotation should be preserved")
}
got := GetSupportedIdentities(cmd)
if len(got) != 1 || got[0] != "bot" {
t.Errorf("expected [bot], got %v", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"sync/atomic"
"github.com/spf13/cobra"
)
// Cobra keeps completion callbacks in a package-global map keyed by
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
// outlive the command itself. Default to disabled (zero value = false) and let
// callers that actually serve a completion request opt in via
// SetFlagCompletionsEnabled(true).
var flagCompletionsEnabled atomic.Bool
// SetFlagCompletionsEnabled toggles whether RegisterFlagCompletion actually
// registers callbacks with cobra. Typically set once at process start.
func SetFlagCompletionsEnabled(enabled bool) {
flagCompletionsEnabled.Store(enabled)
}
// FlagCompletionsEnabled reports the current switch state.
func FlagCompletionsEnabled() bool {
return flagCompletionsEnabled.Load()
}
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
// and honors the package switch. The underlying error is swallowed to match
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
if !flagCompletionsEnabled.Load() {
return
}
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
)
func TestSetFlagCompletionsEnabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
if FlagCompletionsEnabled() {
t.Fatal("expected default false (completions disabled by default)")
}
SetFlagCompletionsEnabled(true)
if !FlagCompletionsEnabled() {
t.Fatal("expected true after Set(true)")
}
SetFlagCompletionsEnabled(false)
if FlagCompletionsEnabled() {
t.Fatal("expected false after Set(false)")
}
}
// When disabled, a *cobra.Command must be collectable after the caller drops
// its reference — i.e. the wrapper did not touch cobra's global map.
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
SetFlagCompletionsEnabled(false)
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
const N = 5
var collected atomic.Int32
func() {
for range N {
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
})
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
}
}()
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
for range 30 {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
if got := collected.Load(); int(got) != N {
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
}
}
// When enabled, the registered completion must be reachable via cobra.
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
SetFlagCompletionsEnabled(true)
t.Cleanup(func() { SetFlagCompletionsEnabled(false) })
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
want := []cobra.Completion{"a", "b"}
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return want, cobra.ShellCompDirectiveNoFileComp
})
fn, ok := cmd.GetFlagCompletionFunc("foo")
if !ok {
t.Fatal("expected completion func to be registered")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected completion result: %v", got)
}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/errs"
)
// RequireConfirmation constructs a typed *errs.ConfirmationRequiredError
// (exit code ExitConfirmationRequired) carrying the risk level and action as
// typed extension fields. Used by both shortcut and service command execution
// paths when a statically high-risk-write operation has not been confirmed
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestRequireConfirmation_TypedShape(t *testing.T) {
err := RequireConfirmation("drive +delete")
if err == nil {
t.Fatal("expected non-nil error")
}
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
}
if cre.Category != errs.CategoryConfirmation {
t.Errorf("Category = %q, want %q", cre.Category, errs.CategoryConfirmation)
}
if cre.Subtype != errs.SubtypeConfirmationRequired {
t.Errorf("Subtype = %q, want %q", cre.Subtype, errs.SubtypeConfirmationRequired)
}
if got := output.ExitCodeOf(err); got != output.ExitConfirmationRequired {
t.Errorf("ExitCodeOf = %d, want %d", got, output.ExitConfirmationRequired)
}
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
}
if cre.Action != "drive +delete" {
t.Errorf("Action = %q, want drive +delete", cre.Action)
}
}
func TestRequireConfirmation_JSONShape(t *testing.T) {
err := RequireConfirmation("mail +send")
var cre *errs.ConfirmationRequiredError
if !errors.As(err, &cre) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
}
raw, mErr := json.Marshal(cre)
if mErr != nil {
t.Fatalf("marshal: %v", mErr)
}
var back map[string]interface{}
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
if back["risk"] != "high-risk-write" {
t.Errorf("risk in JSON = %v", back["risk"])
}
if back["action"] != "mail +send" {
t.Errorf("action in JSON = %v", back["action"])
}
// Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak.
if _, has := back["upgraded_by"]; has {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}
+297
View File
@@ -0,0 +1,297 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"fmt"
"io"
"net/url"
"sort"
"strings"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
// DryRunAPICall describes a single API call in dry-run output.
type DryRunAPICall struct {
Desc string `json:"desc,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params,omitempty"`
Body interface{} `json:"body,omitempty"`
}
// DryRunAPI is the builder and result type for dry-run output.
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
type DryRunAPI struct {
desc string
calls []DryRunAPICall
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
return &DryRunAPI{extra: map[string]interface{}{}}
}
// --- HTTP method builders (add a call, return self for chaining) ---
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
return d
}
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Body = body
}
return d
}
// Params sets query parameters on the last added call.
func (d *DryRunAPI) Params(params map[string]interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Params = params
}
return d
}
// Desc sets a description on the last added call.
// If no calls exist yet, sets the top-level description.
func (d *DryRunAPI) Desc(desc string) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Desc = desc
} else {
d.desc = desc
}
return d
}
// Set adds an extra context field. Values are also used to resolve :key placeholders in URLs.
func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
d.extra[key] = value
return d
}
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
func (d *DryRunAPI) resolveURL(rawURL string) string {
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
resolved := make([]DryRunAPICall, len(d.calls))
for i, c := range d.calls {
resolved[i] = DryRunAPICall{
Desc: c.Desc,
Method: c.Method,
URL: d.resolveURL(c.URL),
Params: c.Params,
Body: c.Body,
}
}
m := make(map[string]interface{}, len(d.extra)+2)
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
for k, v := range d.extra {
m[k] = v
}
return json.Marshal(m)
}
// Format renders the dry-run output as plain text for AI/human consumption.
func (d *DryRunAPI) Format() string {
var b strings.Builder
if d.desc != "" {
b.WriteString("# ")
b.WriteString(d.desc)
b.WriteByte('\n')
}
for i, c := range d.calls {
if i > 0 || d.desc != "" {
b.WriteByte('\n')
}
if c.Desc != "" {
b.WriteString("# ")
b.WriteString(c.Desc)
b.WriteByte('\n')
}
u := d.resolveURL(c.URL)
if len(c.Params) > 0 {
u += "?" + encodeParams(c.Params)
}
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
if !util.IsNil(c.Body) {
j, _ := json.Marshal(c.Body)
b.WriteString(" ")
b.Write(j)
b.WriteByte('\n')
}
}
if len(d.calls) == 0 && len(d.extra) > 0 {
if d.desc != "" {
b.WriteByte('\n')
}
keys := make([]string, 0, len(d.extra))
for k := range d.extra {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
sv := dryRunFormatValue(d.extra[k])
if sv == "" {
continue
}
b.WriteString(k)
b.WriteString(": ")
b.WriteString(sv)
b.WriteByte('\n')
}
}
return b.String()
}
func dryRunFormatValue(v interface{}) string {
switch val := v.(type) {
case string:
return val
case nil:
return ""
default:
j, _ := json.Marshal(val)
return string(j)
}
}
func encodeParams(params map[string]interface{}) string {
vals := url.Values{}
for k, v := range params {
vals.Set(k, fmt.Sprintf("%v", v))
}
return vals.Encode()
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
filePathDisplay := filePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": fileField, "path": filePathDisplay},
}
if formFields != nil {
fileInfo["form_fields"] = formFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}
+178
View File
@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
func TestDryRunAPI_SingleGET(t *testing.T) {
dr := NewDryRunAPI().
Desc("list calendars").
GET("/open-apis/calendar/v4/calendars")
text := dr.Format()
if !strings.Contains(text, "# list calendars") {
t.Errorf("expected description in text output, got: %s", text)
}
if !strings.Contains(text, "GET /open-apis/calendar/v4/calendars") {
t.Errorf("expected GET line in text output, got: %s", text)
}
}
func TestDryRunAPI_WithParams(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/test").
Params(map[string]interface{}{"page_size": 20})
text := dr.Format()
if !strings.Contains(text, "page_size=20") {
t.Errorf("expected query params in text output, got: %s", text)
}
}
func TestDryRunAPI_WithBody(t *testing.T) {
dr := NewDryRunAPI().
POST("/open-apis/test").
Body(map[string]interface{}{"title": "hello"})
text := dr.Format()
if !strings.Contains(text, "POST /open-apis/test") {
t.Errorf("expected POST line, got: %s", text)
}
if !strings.Contains(text, `"title"`) {
t.Errorf("expected body in output, got: %s", text)
}
}
func TestDryRunAPI_ResolveURL(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/calendar/v4/calendars/:calendar_id/events").
Set("calendar_id", "cal_abc123")
text := dr.Format()
if !strings.Contains(text, "cal_abc123") {
t.Errorf("expected resolved calendar_id in URL, got: %s", text)
}
if strings.Contains(text, ":calendar_id") {
t.Errorf("expected placeholder to be resolved, got: %s", text)
}
}
func TestDryRunAPI_MarshalJSON(t *testing.T) {
dr := NewDryRunAPI().
Desc("test api").
GET("/open-apis/test").
Set("as", "user")
data, err := json.Marshal(dr)
if err != nil {
t.Fatalf("MarshalJSON failed: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if m["description"] != "test api" {
t.Errorf("expected description, got: %v", m["description"])
}
if m["as"] != "user" {
t.Errorf("expected as=user, got: %v", m["as"])
}
api, ok := m["api"].([]interface{})
if !ok || len(api) != 1 {
t.Errorf("expected 1 api call, got: %v", m["api"])
}
}
func TestDryRunAPI_MultipleCalls(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/first").Desc("step 1").
POST("/open-apis/second").Desc("step 2")
text := dr.Format()
if !strings.Contains(text, "# step 1") || !strings.Contains(text, "# step 2") {
t.Errorf("expected both step descriptions, got: %s", text)
}
if !strings.Contains(text, "GET /open-apis/first") || !strings.Contains(text, "POST /open-apis/second") {
t.Errorf("expected both calls, got: %s", text)
}
}
func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
dr := NewDryRunAPI().
Desc("info only").
Set("calendar_id", "cal_123").
Set("summary", "My Calendar")
text := dr.Format()
if !strings.Contains(text, "calendar_id: cal_123") {
t.Errorf("expected extra field, got: %s", text)
}
if !strings.Contains(text, "summary: My Calendar") {
t.Errorf("expected extra field, got: %s", text)
}
}
func TestPrintDryRun_JSON(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "user",
}, &core.CliConfig{AppID: "app123"}, "json")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "=== Dry Run ===") {
t.Errorf("expected header, got: %s", out)
}
if !strings.Contains(out, "app123") {
t.Errorf("expected appId in output, got: %s", out)
}
}
func TestPrintDryRun_Pretty(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "POST",
URL: "/open-apis/test",
Data: map[string]interface{}{"key": "val"},
As: "bot",
}, &core.CliConfig{AppID: "app456"}, "pretty")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "POST /open-apis/test") {
t.Errorf("expected POST line in pretty output, got: %s", out)
}
}
func TestDryRunFormatValue(t *testing.T) {
tests := []struct {
name string
v interface{}
want string
}{
{"string", "hello", "hello"},
{"nil", nil, ""},
{"number", 42, "42"},
{"bool", true, "true"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dryRunFormatValue(tt.v); got != tt.want {
t.Errorf("dryRunFormatValue(%v) = %q, want %q", tt.v, got, tt.want)
}
})
}
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
)
// Factory holds shared dependencies injected into every command.
// All function fields are lazily initialized and cached after first call.
// In tests, replace any field to stub out external dependencies.
type InvocationContext struct {
Profile string
}
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
CurrentCommand *cobra.Command // last matched command being executed; set during PersistentPreRun
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
if f == nil || f.FileIOProvider == nil {
return nil
}
return f.FileIOProvider.ResolveFileIO(ctx)
}
// ResolveAs returns the effective identity type.
// If the user explicitly passed --as, use that value; otherwise use the configured default.
// When the value is "auto" (or unset), auto-detect based on credential hints.
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
f.IdentityAutoDetected = false
if cmd != nil && cmd.Flags().Changed("as") {
if flagAs != core.AsAuto {
f.ResolvedIdentity = flagAs
return flagAs
}
// --as auto: fall through to auto-detect
}
mode := f.ResolveStrictMode(ctx)
// Strict mode forces implicit identity choices. Explicit --as user/bot is
// preserved above so CheckStrictMode can reject incompatible requests.
if forced := mode.ForcedIdentity(); forced != "" {
f.ResolvedIdentity = forced
return forced
}
hint := f.resolveIdentityHint(ctx)
if cmd == nil || !cmd.Flags().Changed("as") {
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {
f.ResolvedIdentity = defaultAs
return f.ResolvedIdentity
}
}
// Auto-detect based on credential hint
f.IdentityAutoDetected = true
result := autoDetectIdentityFromHint(hint)
f.ResolvedIdentity = result
return result
}
func resolveDefaultAsFromHint(hint *credential.IdentityHint) core.Identity {
if hint != nil {
return hint.DefaultAs
}
return ""
}
func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
if hint != nil && hint.AutoAs != "" {
return hint.AutoAs
}
return core.AsBot
}
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
if f.Credential == nil {
return nil
}
hint, err := f.Credential.ResolveIdentityHint(ctx)
if err != nil {
return nil
}
return hint
}
// CheckIdentity verifies the resolved identity is in the supported list.
// On success, sets f.ResolvedIdentity. On failure, returns an error
// tailored to whether the identity was explicit (--as) or auto-detected.
func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
for _, t := range supported {
if string(as) == t {
f.ResolvedIdentity = as
return nil
}
}
list := strings.Join(supported, ", ")
if f.IdentityAutoDetected {
base := errs.NewValidationError(errs.SubtypeInvalidArgument,
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s",
as, list).
WithParam("--as")
if len(supported) > 0 {
return base.WithHint("use --as %s", supported[0])
}
return base
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--as %s is not supported, this command only supports: %s", as, list).
WithParam("--as")
}
// ResolveStrictMode returns the effective strict mode by reading
// Account.SupportedIdentities from the credential provider chain.
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
if f.Credential == nil {
return core.StrictModeOff
}
acct, err := f.Credential.ResolveAccount(ctx)
if err != nil || acct == nil {
return core.StrictModeOff
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
switch {
case ids.BotOnly():
return core.StrictModeBot
case ids.UserOnly():
return core.StrictModeUser
default:
return core.StrictModeOff
}
}
// CheckStrictMode returns an error if strict mode is active and identity is not allowed.
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()).
WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
return nil
}
// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only).
// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead.
func (f *Factory) NewAPIClient() (*client.APIClient, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
return f.NewAPIClientWithConfig(cfg)
}
// NewAPIClientWithConfig creates an APIClient with an explicit config.
// Use this when the caller has already resolved the correct config.
func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) {
sdk, err := f.LarkClient()
if err != nil {
return nil, err
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
errOut := io.Discard
if f.IOStreams != nil {
errOut = f.IOStreams.ErrOut
}
return &client.APIClient{
Config: cfg,
SDK: sdk,
HTTP: httpClient,
ErrOut: errOut,
Credential: f.Credential,
}, nil
}
// RequireBuiltinCredentialProvider returns a typed validation error when an
// extension provider is actively managing credentials. Intended for use as
// PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
// - No extension provider is active (built-in keychain/config path is used)
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
if f.Credential == nil {
return nil
}
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if provName == "" {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%q is not supported: credentials are provided externally and do not support interactive management", command).
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
// NewDefault creates a production Factory with cached closures.
// Initialization follows a credential-first order:
//
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
}
// Workspace detection: determines which config subtree to use.
// Must run before any config or credential load, since those paths are
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
keychain.RuntimeDirFunc = core.GetRuntimeDir
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return nil, err
}
cfg := acct.ToCliConfig()
registry.InitWithBrand(cfg.Brand)
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
return f
}
// safeRedirectPolicy prevents credential headers from being forwarded
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
// redirects; other headers like X-Cli-* pass through.
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
req.Header.Del("Authorization")
req.Header.Del("X-Lark-MCP-UAT")
req.Header.Del("X-Lark-MCP-TAT")
}
return nil
}
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
// needed because the real function is guarded by an internal sync.Once, so
// calling it directly would only fire on the first test (see
// factory_proxy_warn_test.go). The terminal check is the IOStreams
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
client := &http.Client{
Transport: rt,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
return client, nil
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return nil, err
}
opts := []lark.ClientOptionFunc{
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
providers := extcred.Providers()
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
// happens before the command runs, so any plain-text warning written
// to stderr would break the JSON envelope contract that AI agents
// depend on. enrichUserInfo failures are already non-fatal (the
// provider clears unverified identity fields), so silencing the
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"testing"
"github.com/larksuite/cli/errs"
_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
type countingFileIOProvider struct {
resolveCalls int
}
func (p *countingFileIOProvider) Name() string { return "counting" }
func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
p.resolveCalls++
return &localfileio.LocalFileIO{}
}
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
bot := core.StrictModeBot
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.PlainSecret("secret-target"),
Brand: core.BrandFeishu,
StrictMode: &bot,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(nil, InvocationContext{Profile: "target"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
if cfg.ProfileName != "target" {
t.Fatalf("Config() profile = %q, want %q", cfg.ProfileName, "target")
}
if cfg.AppID != "app-target" {
t.Fatalf("Config() appID = %q, want %q", cfg.AppID, "app-target")
}
}
func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(nil, InvocationContext{Profile: "missing"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
}
_, err := f.Config()
if err == nil {
t.Fatal("Config() error = nil, want non-nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("Config() error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Message != `profile "missing" not found` {
t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`)
}
}
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "user")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsUser {
t.Fatalf("ResolveAs() = %q, want %q", got, core.AsUser)
}
if f.IdentityAutoDetected {
t.Fatal("IdentityAutoDetected = true, want false")
}
}
func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "uat-token")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
cfg.AppID = "mutated-cli-config"
if acct.AppID != "env-app" {
t.Fatalf("credential account mutated via Config(): got %q, want %q", acct.AppID, "env-app")
}
}
func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "uat-token")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct.AppSecret != "" {
t.Fatalf("credential account AppSecret = %q, want empty string", acct.AppSecret)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
if cfg.AppSecret != "" {
t.Fatalf("Config().AppSecret = %q, want empty string for token-only account", cfg.AppSecret)
}
if credential.HasRealAppSecret(cfg.AppSecret) {
t.Fatalf("Config().AppSecret = %q, want token-only no-secret marker", cfg.AppSecret)
}
}
func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) {
prev := fileio.GetProvider()
provider := &countingFileIOProvider{}
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })
f := NewDefault(nil, InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls)
}
if got := f.ResolveFileIO(context.Background()); got == nil {
t.Fatal("ResolveFileIO() = nil, want non-nil")
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"testing"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c1, err := fn()
if err != nil {
t.Fatalf("first call: %v", err)
}
if c1 == nil {
t.Fatal("first call returned nil")
}
c2, err := fn()
if err != nil {
t.Fatalf("second call: %v", err)
}
if c1 != c2 {
t.Error("expected same *http.Client instance on second call (cache hit)")
}
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
}
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
}
}
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/envvars"
)
// installProxyWarnSpy replaces warnIfProxied with a counter for one test and
// restores it on cleanup. Returns a pointer to the call count so the caller can
// assert how many times the warning fired. The terminal state is controlled via
// the IOStreams.StderrIsTerminal field, not a seam.
func installProxyWarnSpy(t *testing.T) *int {
t.Helper()
prevWarn := warnIfProxied
t.Cleanup(func() { warnIfProxied = prevWarn })
calls := 0
warnIfProxied = func(io.Writer) { calls++ }
return &calls
}
var proxyWarnGateCases = []struct {
name string
terminal bool
want int
}{
{"terminal stderr warns once", true, 1},
{"non-terminal stderr stays silent", false, 0},
}
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
if *calls != tc.want {
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
}
})
}
}
// TestCachedLarkClientFunc_ProxyWarnGate verifies the lark-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal. The gate
// runs after ResolveAccount, so an env-backed credential is wired up to let
// account resolution succeed without network or config files.
func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
calls := installProxyWarnSpy(t)
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
t.Fatalf("lark client init: %v", err)
}
if *calls != tc.want {
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
}
})
}
}

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