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
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user