mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0cdc8879 | ||
|
|
67e51ec8d7 | ||
|
|
5943a20e2b | ||
|
|
cd666422ac | ||
|
|
9acd121259 | ||
|
|
1262aac480 | ||
|
|
abb02cd46c | ||
|
|
db7d3cb64d | ||
|
|
5134719da9 | ||
|
|
5a0e1d3dd9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ tests/mail/reports/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.15] - 2026-04-20
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add float image shortcuts (#494)
|
||||
- **approval**: Document `remind` and `initiated` methods in skill (#554)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Preserve attachment metadata on base uploads (#563)
|
||||
- **base**: Fix role view and record default permission on edit (#530)
|
||||
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
|
||||
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
|
||||
- **install**: Refine install wizard messages (#529)
|
||||
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
|
||||
|
||||
## [v1.0.14] - 2026-04-17
|
||||
|
||||
### Features
|
||||
@@ -404,6 +420,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
|
||||
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
|
||||
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
|
||||
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package credential
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
@@ -11,12 +14,28 @@ var (
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in registration order.
|
||||
// Providers are consulted in priority order (lowest value first).
|
||||
// Providers that implement Priority() int are sorted accordingly;
|
||||
// those that do not default to priority 10.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
sort.SliceStable(providers, func(i, j int) bool {
|
||||
return providerPriority(providers[i]) < providerPriority(providers[j])
|
||||
})
|
||||
}
|
||||
|
||||
// providerPriority returns the priority of a provider.
|
||||
// If the provider implements interface{ Priority() int }, that value is used;
|
||||
// otherwise 10 is returned as the default priority.
|
||||
// Lower values are consulted first.
|
||||
func providerPriority(p Provider) int {
|
||||
if pp, ok := p.(interface{ Priority() int }); ok {
|
||||
return pp.Priority()
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
|
||||
@@ -37,6 +37,32 @@ func TestRegisterAndProviders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type priorityProvider struct {
|
||||
stubProvider
|
||||
priority int
|
||||
}
|
||||
|
||||
func (p *priorityProvider) Priority() int { return p.priority }
|
||||
|
||||
func TestRegister_PriorityOrder(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "env"}) // priority 10 (default)
|
||||
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
|
||||
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
|
||||
131
extension/credential/sidecar/provider.go
Normal file
131
extension/credential/sidecar/provider.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a noop credential provider for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
|
||||
// placeholder credentials so the CLI's auth pipeline can proceed normally.
|
||||
// Real tokens are never present in the sandbox; the sidecar transport
|
||||
// interceptor routes requests to the trusted sidecar process instead.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider is the noop credential provider for sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
func (p *Provider) Priority() int { return 0 }
|
||||
|
||||
// ResolveAccount returns a minimal Account when sidecar mode is active.
|
||||
// The account contains AppID and Brand from environment variables, a
|
||||
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
|
||||
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil, nil // not in sidecar mode, skip
|
||||
}
|
||||
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
|
||||
}
|
||||
}
|
||||
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv(envvars.CliProxyKey) == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
}
|
||||
|
||||
// Parse DefaultAs
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off", "":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// ResolveToken returns a sentinel token whose value encodes the token type.
|
||||
// The transport interceptor reads this sentinel to determine the identity
|
||||
// (user vs bot), strips it, and the sidecar injects the real token.
|
||||
// Returns nil, nil when sidecar mode is not active.
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
if os.Getenv(envvars.CliAuthProxy) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sentinel string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
sentinel = sidecar.SentinelUAT
|
||||
case credential.TokenTypeTAT:
|
||||
sentinel = sidecar.SentinelTAT
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &credential.Token{
|
||||
Value: sentinel,
|
||||
Scopes: "", // empty → scope pre-check is skipped
|
||||
Source: "sidecar",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
188
extension/credential/sidecar/provider_test.go
Normal file
188
extension/credential/sidecar/provider_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Setenv(key, value)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
} else {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAccount_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatal("expected nil account when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_Active(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test123")
|
||||
setEnv(t, envvars.CliBrand, "lark")
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected non-nil account")
|
||||
}
|
||||
if acct.AppID != "cli_test123" {
|
||||
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
|
||||
}
|
||||
if acct.Brand != credential.BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingProxyKey(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
unsetEnv(t, envvars.CliProxyKey)
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PROXY_KEY is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingAppID(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
unsetEnv(t, envvars.CliAppID)
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when APP_ID is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
mode string
|
||||
want credential.IdentitySupport
|
||||
}{
|
||||
{"bot", credential.SupportsBot},
|
||||
{"user", credential.SupportsUser},
|
||||
{"off", credential.SupportsAll},
|
||||
{"", credential.SupportsAll},
|
||||
}
|
||||
|
||||
p := &Provider{}
|
||||
for _, tt := range tests {
|
||||
t.Run("strict_"+tt.mode, func(t *testing.T) {
|
||||
if tt.mode == "" {
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
} else {
|
||||
setEnv(t, envvars.CliStrictMode, tt.mode)
|
||||
}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct.SupportedIdentities != tt.want {
|
||||
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != nil {
|
||||
t.Fatal("expected nil token when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_Sentinels(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
// UAT
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("UAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelUAT {
|
||||
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
|
||||
}
|
||||
if tok.Scopes != "" {
|
||||
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
|
||||
}
|
||||
|
||||
// TAT
|
||||
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatalf("TAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelTAT {
|
||||
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
|
||||
}
|
||||
}
|
||||
178
extension/transport/sidecar/interceptor.go
Normal file
178
extension/transport/sidecar/interceptor.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider implements transport.Provider for the sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
|
||||
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
|
||||
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
|
||||
// in the latter case a warning is emitted to stderr and requests fall back to
|
||||
// the non-sidecar transport path (where the credential layer will typically
|
||||
// block them for lack of a valid account).
|
||||
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
|
||||
return nil
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
|
||||
// URLs) are passed through unmodified.
|
||||
//
|
||||
// Supports two auth patterns:
|
||||
// - Standard OpenAPI: Authorization: Bearer <sentinel>
|
||||
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
|
||||
identity, authHeader := detectSentinel(req)
|
||||
if identity == "" {
|
||||
return nil // not a sidecar-managed request, pass through
|
||||
}
|
||||
|
||||
// 1. Buffer the body first, before mutating any request state. A partial
|
||||
// read would sign a truncated body and cause a misleading HMAC mismatch
|
||||
// on the sidecar side; bail out early and let the request fall through
|
||||
// unmodified so the credential layer can surface an actionable error.
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
if req.GetBody != nil {
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Save original target (scheme://host)
|
||||
originalScheme := "https"
|
||||
if req.URL.Scheme != "" {
|
||||
originalScheme = req.URL.Scheme
|
||||
}
|
||||
originalHost := req.URL.Host
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
|
||||
|
||||
// 3. Set identity and tell sidecar which header to inject real token into
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
||||
|
||||
// 4. Strip placeholder auth header(s)
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del(sidecar.HeaderMCPUAT)
|
||||
req.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
bodySHA := sidecar.BodySHA256(bodyBytes)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
|
||||
pathAndQuery := req.URL.RequestURI()
|
||||
ts := sidecar.Timestamp()
|
||||
// Cover identity and authHeader in the signature so an on-path attacker
|
||||
// within the replay window cannot flip the injected token's identity or
|
||||
// redirect the token into a different header.
|
||||
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: req.Method,
|
||||
Host: originalHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: bodySHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
})
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
}
|
||||
|
||||
// detectSentinel checks both standard Authorization and MCP auth headers for
|
||||
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
|
||||
// that carried the sentinel.
|
||||
//
|
||||
// Returns ("", "") when the request carries no sentinel token — typically
|
||||
// requests that require no auth (e.g. pre-signed download URLs where the
|
||||
// token is embedded in the URL query parameters).
|
||||
func detectSentinel(req *http.Request) (identity, authHeader string) {
|
||||
// Check standard Authorization: Bearer <sentinel>
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
switch token {
|
||||
case sidecar.SentinelUAT:
|
||||
return sidecar.IdentityUser, "Authorization"
|
||||
case sidecar.SentinelTAT:
|
||||
return sidecar.IdentityBot, "Authorization"
|
||||
}
|
||||
}
|
||||
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
|
||||
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
|
||||
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
|
||||
return
|
||||
}
|
||||
transport.Register(&Provider{})
|
||||
}
|
||||
265
extension/transport/sidecar/interceptor_test.go
Normal file
265
extension/transport/sidecar/interceptor_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
closed bool
|
||||
readCall bool
|
||||
}
|
||||
|
||||
func (b *failingBody) Read(p []byte) (int, error) {
|
||||
b.readCall = true
|
||||
return 0, b.err
|
||||
}
|
||||
|
||||
func (b *failingBody) Close() error {
|
||||
b.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
body := []byte(`{"msg":"hello"}`)
|
||||
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
req.Header.Set("X-Cli-Source", "lark-cli")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook")
|
||||
}
|
||||
|
||||
// URL should be rewritten to sidecar
|
||||
if req.URL.Scheme != "http" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
|
||||
}
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
|
||||
}
|
||||
|
||||
// Original target should be preserved
|
||||
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
||||
if target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
|
||||
// Identity should be user (from SentinelUAT)
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
|
||||
// Authorization should be stripped
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("Authorization header should be stripped, got %q", auth)
|
||||
}
|
||||
|
||||
// HMAC headers should be set
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
|
||||
t.Error("timestamp header should be set")
|
||||
}
|
||||
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
|
||||
t.Error("body SHA256 header should be set")
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
|
||||
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
|
||||
}
|
||||
|
||||
// Non-proxy headers should be preserved
|
||||
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
|
||||
t.Errorf("X-Cli-Source should be preserved, got %q", src)
|
||||
}
|
||||
|
||||
// Body should still be readable
|
||||
readBody, _ := io.ReadAll(req.Body)
|
||||
if !bytes.Equal(readBody, body) {
|
||||
t.Errorf("body should be preserved after PreRoundTrip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer some-real-token")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should NOT be rewritten — no sentinel token
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook for pass-through")
|
||||
}
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged, got %q", req.URL.String())
|
||||
}
|
||||
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
|
||||
t.Error("proxy target header should not be set for pass-through")
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer some-real-token" {
|
||||
t.Error("Authorization should be preserved for pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://cdn.feishu.cn/download/file"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// No Authorization header at all — should pass through
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_UAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should be intercepted and rewritten
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want sidecar host", req.URL.Host)
|
||||
}
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
|
||||
}
|
||||
// MCP sentinel should be stripped
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Errorf("MCP-UAT should be stripped, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_TAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
|
||||
t.Errorf("auth header = %q, want %q", ah, "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
|
||||
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
|
||||
// signing a truncated body (which would produce a misleading HMAC mismatch on
|
||||
// the sidecar side) and releases the original body.
|
||||
func TestInterceptor_BodyReadError(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
body := &failingBody{err: errors.New("disk gremlin")}
|
||||
|
||||
req, _ := http.NewRequest("POST", origURL, body)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook on body read failure")
|
||||
}
|
||||
|
||||
// Original body must be closed to avoid leaking fd/pipe-like resources.
|
||||
if !body.readCall {
|
||||
t.Error("expected ReadAll to have attempted reading from the body")
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("expected original body to be Close()'d after read failure")
|
||||
}
|
||||
|
||||
// URL must NOT be rewritten — request should fall through to the next
|
||||
// layer (credential) which can surface a meaningful error.
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
|
||||
}
|
||||
|
||||
// No proxy/HMAC headers should leak onto the request.
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion,
|
||||
sidecar.HeaderProxyTarget,
|
||||
sidecar.HeaderProxySignature,
|
||||
sidecar.HeaderProxyTimestamp,
|
||||
sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if v := req.Header.Get(h); v != "" {
|
||||
t.Errorf("%s should not be set on read failure, got %q", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
sha := req.Header.Get(sidecar.HeaderBodySHA256)
|
||||
expectedEmpty := sidecar.BodySHA256(nil)
|
||||
if sha != expectedEmpty {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -122,9 +123,22 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
// Add top-level JSON keys as text form fields.
|
||||
if m, ok := dataJSON.(map[string]any); ok {
|
||||
for k, v := range m {
|
||||
fd.AddField(k, fmt.Sprintf("%v", v))
|
||||
fd.AddField(k, formatFormFieldValue(v))
|
||||
}
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
|
||||
// field string. float64 is handled specially: fmt's default %v/%g switches to
|
||||
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
|
||||
// backends reject when parsing the field as an integer. Use decimal notation
|
||||
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
|
||||
// All other types fall through to %v.
|
||||
func formatFormFieldValue(v any) string {
|
||||
if n, ok := v.(float64); ok {
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
@@ -336,3 +336,40 @@ func TestBuildFormdata(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
|
||||
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
|
||||
// float64 delegates to %g which switches to scientific notation at ~1e6
|
||||
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
|
||||
// integer reject that, surfacing as a generic "params error".
|
||||
func TestFormatFormFieldValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
|
||||
{"float64 below scientific threshold", float64(358934), "358934"},
|
||||
{"float64 zero", float64(0), "0"},
|
||||
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
|
||||
{"float64 negative", float64(-42), "-42"},
|
||||
{"float64 fractional preserved", float64(3.14), "3.14"},
|
||||
{"string pass-through", "hello", "hello"},
|
||||
{"bool true", true, "true"},
|
||||
{"int via %v", 42, "42"},
|
||||
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatFormFieldValue(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,8 @@ const (
|
||||
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
|
||||
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
)
|
||||
|
||||
@@ -38,6 +38,9 @@ const (
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -73,6 +76,12 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
|
||||
// sheets-specific constraints that benefit from actionable hints
|
||||
case LarkErrSheetsFloatImageInvalidDims:
|
||||
return ExitAPI, "invalid_params",
|
||||
"check --width / --height / --offset-x / --offset-y: " +
|
||||
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
@@ -40,6 +40,13 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
{
|
||||
name: "sheets float image invalid dims",
|
||||
code: LarkErrSheetsFloatImageInvalidDims,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "invalid_params",
|
||||
wantHint: "--width / --height / --offset-x / --offset-y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
11
main_authsidecar.go
Normal file
11
main_authsidecar.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider
|
||||
_ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor
|
||||
)
|
||||
54
main_noauthsidecar.go
Normal file
54
main_noauthsidecar.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !authsidecar
|
||||
|
||||
// This file is the fail-closed guard for builds that do NOT include the
|
||||
// `authsidecar` tag. The sidecar credential-isolation feature is only
|
||||
// compiled in under that tag; deploying the plain build into an environment
|
||||
// that expects sidecar isolation would silently fall back to direct env
|
||||
// credential use — exactly the failure mode the feature is meant to prevent.
|
||||
//
|
||||
// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore
|
||||
// the variable. The operator either rebuilt without realizing (wrong
|
||||
// artifact) or the sandbox inherited the var by accident; both cases want
|
||||
// a loud startup error, not a mysterious token leak on the first API call.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 {
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a
|
||||
// human-readable reason to stderr) when the environment asks for sidecar
|
||||
// isolation that this binary cannot provide. Factored out from init() so
|
||||
// tests can exercise the decision without actually calling os.Exit.
|
||||
func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int {
|
||||
v := getenv(envvars.CliAuthProxy)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stderr,
|
||||
"ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+
|
||||
"'authsidecar' build tag.\n"+
|
||||
"The sidecar credential-isolation feature is compiled out — "+
|
||||
"running would bypass isolation and\n"+
|
||||
"send any real credentials present in the environment directly "+
|
||||
"to the Lark API.\n\n"+
|
||||
"To fix, either:\n"+
|
||||
" - rebuild the CLI with: go build -tags authsidecar\n"+
|
||||
" - or unset %s if sidecar isolation is not required\n",
|
||||
envvars.CliAuthProxy, envvars.CliAuthProxy)
|
||||
return 2
|
||||
}
|
||||
52
main_noauthsidecar_test.go
Normal file
52
main_noauthsidecar_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !authsidecar
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr)
|
||||
if code != 0 {
|
||||
t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr should be empty, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into
|
||||
// a sandbox that expects sidecar isolation fails loudly at startup instead
|
||||
// of silently leaking credentials through the env provider path.
|
||||
func TestCheckNoAuthsidecarBuild_Set(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
env := func(k string) string {
|
||||
if k == envvars.CliAuthProxy {
|
||||
return "http://127.0.0.1:16384"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
code := checkNoAuthsidecarBuild(env, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("expected non-zero exit code when AUTH_PROXY is set")
|
||||
}
|
||||
msg := stderr.String()
|
||||
for _, want := range []string{
|
||||
envvars.CliAuthProxy,
|
||||
"authsidecar", // build-tag name must appear so operators can act on it
|
||||
"rebuild",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("stderr message missing %q; got:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.14",
|
||||
"version": "1.0.15",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -38,11 +38,11 @@ const messages = {
|
||||
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
|
||||
step4: "授权",
|
||||
step4NotFound: "未找到 lark-cli,跳过授权",
|
||||
step4Confirm: "允许 AI 访问你的飞书数据(消息、文档、日历等)?",
|
||||
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作?",
|
||||
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
|
||||
step4Done: "授权完成",
|
||||
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
|
||||
done: "安装完成!\n现在可以对你的 AI 工具(Claude Code、Trae 等)说:\"Feishu/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等)说:\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
|
||||
cancelled: "安装已取消",
|
||||
},
|
||||
en: {
|
||||
@@ -66,7 +66,7 @@ const messages = {
|
||||
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
|
||||
step4: "Authorization",
|
||||
step4NotFound: "lark-cli not found. Skipping authorization",
|
||||
step4Confirm: "Allow AI to access your Feishu/Lark data (messages, docs, calendar, etc.)?",
|
||||
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
|
||||
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
|
||||
step4Done: "Authorization complete",
|
||||
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
|
||||
|
||||
@@ -137,6 +137,8 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
"bitable_file",
|
||||
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
|
||||
"report-final.pdf",
|
||||
`"mime_type":"\u003cdetected_mime_type\u003e"`,
|
||||
`"size":"\u003cfile_size\u003e"`,
|
||||
"deprecated_set_attachment",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1219,7 +1219,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
!strings.Contains(updateBody, `"image_height":480`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) {
|
||||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
|
||||
!strings.Contains(updateBody, `"size":16`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
})
|
||||
@@ -1370,6 +1372,8 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
if !strings.Contains(updateBody, `"附件"`) ||
|
||||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
|
||||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
|
||||
!strings.Contains(updateBody, `"size":20971521`) ||
|
||||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
|
||||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
|
||||
t.Fatalf("update body=%s", updateBody)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -105,6 +109,8 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
map[string]interface{}{
|
||||
"file_token": "<uploaded_file_token>",
|
||||
"name": fileName,
|
||||
"mime_type": "<detected_mime_type>",
|
||||
"size": "<file_size>",
|
||||
"deprecated_set_attachment": true,
|
||||
},
|
||||
},
|
||||
@@ -243,10 +249,14 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
|
||||
}
|
||||
|
||||
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
|
||||
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentNode := baseToken
|
||||
var (
|
||||
fileToken string
|
||||
err error
|
||||
)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
@@ -272,7 +282,78 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
|
||||
attachment := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"name": fileName,
|
||||
"mime_type": mimeType,
|
||||
"size": fileSize,
|
||||
"deprecated_set_attachment": true,
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
}
|
||||
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" {
|
||||
return stripMIMEParams(byExt), nil
|
||||
}
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, readErr := f.Read(buf)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
|
||||
func stripMIMEParams(value string) string {
|
||||
if i := strings.IndexByte(value, ';'); i != -1 {
|
||||
value = value[:i]
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func detectAttachmentMIMEFromContent(content []byte) string {
|
||||
if len(content) == 0 {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
|
||||
return "image/png"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) {
|
||||
return "image/gif"
|
||||
}
|
||||
if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) {
|
||||
return "image/webp"
|
||||
}
|
||||
if bytes.HasPrefix(content, []byte("%PDF-")) {
|
||||
return "application/pdf"
|
||||
}
|
||||
if looksLikeText(content) {
|
||||
return "text/plain"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func looksLikeText(content []byte) bool {
|
||||
if !utf8.Valid(content) {
|
||||
return false
|
||||
}
|
||||
for _, r := range string(content) {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
continue
|
||||
}
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
136
shortcuts/base/record_upload_attachment_test.go
Normal file
136
shortcuts/base/record_upload_attachment_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
type attachmentTestFileIO struct {
|
||||
openFile fileio.File
|
||||
openErr error
|
||||
}
|
||||
|
||||
func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr }
|
||||
func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) {
|
||||
return attachmentTestFileInfo{}, nil
|
||||
}
|
||||
func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil }
|
||||
func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type attachmentTestFileInfo struct{}
|
||||
|
||||
func (attachmentTestFileInfo) Size() int64 { return 0 }
|
||||
func (attachmentTestFileInfo) IsDir() bool { return false }
|
||||
func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 }
|
||||
|
||||
type attachmentTestFile struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func newAttachmentTestFile(content []byte) attachmentTestFile {
|
||||
return attachmentTestFile{Reader: bytes.NewReader(content)}
|
||||
}
|
||||
|
||||
func (attachmentTestFile) Close() error { return nil }
|
||||
|
||||
type attachmentReadErrorFile struct{}
|
||||
|
||||
func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission }
|
||||
func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF }
|
||||
func (attachmentReadErrorFile) Close() error { return nil }
|
||||
|
||||
func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) {
|
||||
got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "text/plain" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) {
|
||||
got, err := detectAttachmentMIMEType(nil, "report.docx", "report")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))}
|
||||
|
||||
got, err := detectAttachmentMIMEType(fio, "note", "note")
|
||||
if err != nil {
|
||||
t.Fatalf("detectAttachmentMIMEType() error = %v", err)
|
||||
}
|
||||
if got != "text/plain" {
|
||||
t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openErr: os.ErrNotExist}
|
||||
|
||||
_, err := detectAttachmentMIMEType(fio, "missing", "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for open failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error = %v, want wrapped read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) {
|
||||
fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}}
|
||||
|
||||
_, err := detectAttachmentMIMEType(fio, "broken", "broken")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for read failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error = %v, want read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAttachmentMIMEFromContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
want string
|
||||
}{
|
||||
{name: "empty", content: nil, want: "application/octet-stream"},
|
||||
{name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"},
|
||||
{name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"},
|
||||
{name: "gif87a", content: []byte("GIF87a"), want: "image/gif"},
|
||||
{name: "gif89a", content: []byte("GIF89a"), want: "image/gif"},
|
||||
{name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"},
|
||||
{name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"},
|
||||
{name: "text", content: []byte("hello from base attachment"), want: "text/plain"},
|
||||
{name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"},
|
||||
{name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"},
|
||||
{name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectAttachmentMIMEFromContent(tt.content)
|
||||
if got != tt.want {
|
||||
t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -152,8 +152,9 @@ func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]in
|
||||
// This API has lighter permission requirements and works with user identity
|
||||
// even when the target user is not in the app's visible range.
|
||||
// Response uses "users" (not "items") and "user_id" (not "open_id").
|
||||
// The basic_batch endpoint caps user_ids at 10 per request.
|
||||
func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []string, nameMap map[string]string) {
|
||||
const batchSize = 50
|
||||
const batchSize = 10
|
||||
for i := 0; i < len(missingIDs); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(missingIDs) {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -170,6 +172,57 @@ func TestResolveSenderNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchResolveByBasicContactRespectsAPILimit(t *testing.T) {
|
||||
// basic_batch allows at most 10 user_ids per request. Given 25 missing IDs,
|
||||
// expect three requests with sizes 10 / 10 / 5.
|
||||
var batchSizes []int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/contact/v3/users/basic_batch") {
|
||||
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs, _ := payload["user_ids"].([]interface{})
|
||||
if len(userIDs) > 10 {
|
||||
t.Fatalf("batch exceeded API limit: size = %d", len(userIDs))
|
||||
}
|
||||
batchSizes = append(batchSizes, len(userIDs))
|
||||
|
||||
users := make([]interface{}, 0, len(userIDs))
|
||||
for _, raw := range userIDs {
|
||||
id, _ := raw.(string)
|
||||
users = append(users, map[string]interface{}{
|
||||
"user_id": id,
|
||||
"name": "name-" + id,
|
||||
})
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"users": users},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
missingIDs := make([]string, 25)
|
||||
for i := range missingIDs {
|
||||
missingIDs[i] = fmt.Sprintf("ou_%02d", i)
|
||||
}
|
||||
nameMap := map[string]string{}
|
||||
batchResolveByBasicContact(runtime, missingIDs, nameMap)
|
||||
|
||||
if want := []int{10, 10, 5}; !reflect.DeepEqual(batchSizes, want) {
|
||||
t.Fatalf("batch sizes = %v, want %v", batchSizes, want)
|
||||
}
|
||||
if len(nameMap) != 25 {
|
||||
t.Fatalf("resolved name count = %d, want 25", len(nameMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSenderNamesAPIFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
|
||||
@@ -22,7 +22,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
@@ -49,6 +49,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
normalizeBatchStyleRanges(data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
Body(map[string]interface{}{
|
||||
@@ -66,6 +67,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
@@ -81,3 +83,34 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place
|
||||
// so the /styles_batch_update endpoint accepts single-cell shorthand.
|
||||
// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to
|
||||
// "sheet1!A1:A1"; multi-cell spans pass through unchanged.
|
||||
// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be
|
||||
// expanded because the helper has no sheet-id context (the shortcut exposes
|
||||
// no --sheet-id flag), and the backend would reject the payload anyway —
|
||||
// such entries pass through unchanged. Non-string entries, missing
|
||||
// ranges keys, and non-array top-level inputs are ignored silently.
|
||||
func normalizeBatchStyleRanges(data interface{}) {
|
||||
items, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ranges, ok := entry["ranges"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, r := range ranges {
|
||||
if s, ok := r.(string); ok {
|
||||
ranges[i] = normalizePointRange("", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,46 @@ func TestSheetSetStyleExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1",
|
||||
"style": `{"font":{"bold":true}}`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"range":"sheet1!A1:A1"`) {
|
||||
t.Fatalf("DryRun should expand single cell to A1:A1: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetSetStyle, []string{
|
||||
"+set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--range", "A1",
|
||||
"--style", `{"font":{"bold":true}}`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
appendStyle, _ := body["appendStyle"].(map[string]interface{})
|
||||
if appendStyle["range"] != "sheet1!A1:A1" {
|
||||
t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -523,6 +563,51 @@ func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test",
|
||||
"data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`,
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) {
|
||||
t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"totalUpdatedCells": float64(5),
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
|
||||
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
|
||||
"--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
data, _ := body["data"].([]interface{})
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("expected 1 data entry, got %d", len(data))
|
||||
}
|
||||
entry, _ := data[0].(map[string]interface{})
|
||||
ranges, _ := entry["ranges"].([]interface{})
|
||||
if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" {
|
||||
t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -537,3 +622,101 @@ func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBatchStyleRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1", "sheet1!B2"},
|
||||
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" {
|
||||
t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-cell span passes through unchanged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1:B2"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:B2" {
|
||||
t.Fatalf("multi-cell span should be unchanged, got %v", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Without a sheetId! prefix there's no sheet context; entry is left
|
||||
// alone and the backend will reject it. Documented in the helper.
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"A1"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "A1" {
|
||||
t.Fatalf("bare single cell should pass through, got %v", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-string entries are preserved", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"},
|
||||
},
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
got := data[0].(map[string]interface{})["ranges"].([]interface{})
|
||||
if got[0] != "sheet1!A1:A1" {
|
||||
t.Fatalf("first entry should be expanded, got %v", got[0])
|
||||
}
|
||||
if got[1] != 42 {
|
||||
t.Fatalf("int entry should be preserved, got %v", got[1])
|
||||
}
|
||||
if got[2] != nil {
|
||||
t.Fatalf("nil entry should be preserved, got %v", got[2])
|
||||
}
|
||||
if got[3] != "sheet1!B2:B2" {
|
||||
t.Fatalf("last entry should be expanded, got %v", got[3])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing or non-array ranges key is skipped", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"style": map[string]interface{}{"font": map[string]interface{}{"bold": true}},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"ranges": "not-an-array",
|
||||
},
|
||||
"not-a-map",
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
if data[1].(map[string]interface{})["ranges"] != "not-an-array" {
|
||||
t.Fatal("non-array ranges should be left alone")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("top-level non-array inputs do not panic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any of these would panic if the helper didn't guard its type assertions.
|
||||
normalizeBatchStyleRanges(nil)
|
||||
normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"})
|
||||
normalizeBatchStyleRanges("string")
|
||||
normalizeBatchStyleRanges(42)
|
||||
})
|
||||
}
|
||||
|
||||
325
shortcuts/sheets/sheet_float_image.go
Normal file
325
shortcuts/sheets/sheet_float_image.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func floatImageBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func floatImageItemPath(token, sheetID, floatImageID string) string {
|
||||
return fmt.Sprintf("%s/%s", floatImageBasePath(token, sheetID), validate.EncodePathSegment(floatImageID))
|
||||
}
|
||||
|
||||
func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != u {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func validateFloatImageRange(sheetID, rangeVal string) error {
|
||||
if rangeVal == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateSingleCellRange(rangeVal); err != nil {
|
||||
return err
|
||||
}
|
||||
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
|
||||
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageUpdatePayload rejects an update request that carries no
|
||||
// mutable field. Without this, PATCH {} reaches the server as a confusing
|
||||
// no-op or opaque error.
|
||||
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
|
||||
hasField := runtime.Str("range") != "" ||
|
||||
runtime.Cmd.Flags().Changed("width") ||
|
||||
runtime.Cmd.Flags().Changed("height") ||
|
||||
runtime.Cmd.Flags().Changed("offset-x") ||
|
||||
runtime.Cmd.Flags().Changed("offset-y")
|
||||
if !hasField {
|
||||
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageDims checks the numeric bounds we can verify without
|
||||
// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0.
|
||||
// The upper bounds (offset < anchor cell's width/height) are validated by
|
||||
// the server and surfaced through the 1310246 error hint.
|
||||
// Only flags explicitly supplied by the user are checked, so omitted flags
|
||||
// (which fall back to server defaults) pass through unchanged.
|
||||
func validateFloatImageDims(runtime *common.RuntimeContext) error {
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
if v := runtime.Int("width"); v < 20 {
|
||||
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
if v := runtime.Int("height"); v < 20 {
|
||||
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
if v := runtime.Int("offset-x"); v < 0 {
|
||||
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
if v := runtime.Int("offset-y"); v < 0 {
|
||||
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if includeToken {
|
||||
if s := runtime.Str("float-image-token"); s != "" {
|
||||
body["float_image_token"] = s
|
||||
}
|
||||
}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
body["width"] = runtime.Int("width")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
body["height"] = runtime.Int("height")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
body["offset_x"] = runtime.Int("offset-x")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
body["offset_y"] = runtime.Int("offset-y")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// SheetCreateFloatImage creates a float image on a sheet.
|
||||
var SheetCreateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-float-image",
|
||||
Description: "Create a floating image on a sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-token", Desc: "image file token (from upload API)", Required: true},
|
||||
{Name: "range", Desc: "anchor cell, must be a single cell (e.g. sheetId!A1:A1)", Required: true},
|
||||
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
|
||||
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
|
||||
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "float-image-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFloatImageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFloatImageDims(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, true)
|
||||
if s := runtime.Str("float-image-id"); s != "" {
|
||||
body["float_image_id"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, true)
|
||||
if s := runtime.Str("float-image-id"); s != "" {
|
||||
body["float_image_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetUpdateFloatImage updates a float image's properties.
|
||||
var SheetUpdateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-float-image",
|
||||
Description: "Update a floating image",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
{Name: "range", Desc: "new anchor cell, must be a single cell (e.g. sheetId!B2:B2)"},
|
||||
{Name: "width", Type: "int", Desc: "width in pixels (>=20)"},
|
||||
{Name: "height", Type: "int", Desc: "height in pixels (>=20)"},
|
||||
{Name: "offset-x", Type: "int", Desc: "horizontal offset from anchor cell's top-left (pixels, >=0)"},
|
||||
{Name: "offset-y", Type: "int", Desc: "vertical offset from anchor cell's top-left (pixels, >=0)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFloatImageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageUpdatePayload(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFloatImageRange(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFloatImageDims(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, false)
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, false)
|
||||
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetGetFloatImage retrieves a single float image.
|
||||
var SheetGetFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-float-image",
|
||||
Description: "Get a floating image by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetListFloatImages queries all float images in a sheet.
|
||||
var SheetListFloatImages = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-float-images",
|
||||
Description: "List all floating images in a sheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SheetDeleteFloatImage deletes a float image.
|
||||
var SheetDeleteFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-float-image",
|
||||
Description: "Delete a floating image",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "float-image-id", Desc: "float image ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFloatImageToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/float_images/:float_image_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("float_image_id", runtime.Str("float-image-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
524
shortcuts/sheets/sheet_float_image_test.go
Normal file
@@ -0,0 +1,524 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// ── CreateFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateFloatImageValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Pixel flags are int-typed by the shortcut; leave them unset (empty
|
||||
// intFlags map) so Cmd.Flags().Changed(...) returns false and
|
||||
// validateFloatImageDims doesn't try to read non-existent ints.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
}, nil, nil)
|
||||
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsMultiCellRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:B2",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "single cell") {
|
||||
t.Fatalf("expected single-cell error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-token": "boxToken", "range": "other!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "", "float-image-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
|
||||
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
intFlags map[string]int
|
||||
wantSubst string
|
||||
}{
|
||||
{"width below 20", map[string]int{"width": 5}, "--width must be >= 20"},
|
||||
{"height below 20", map[string]int{"height": 10}, "--height must be >= 20"},
|
||||
{"negative offset-x", map[string]int{"offset-x": -1}, "--offset-x must be >= 0"},
|
||||
{"negative offset-y", map[string]int{"offset-y": -5}, "--offset-y must be >= 0"},
|
||||
}
|
||||
|
||||
baseStr := map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
|
||||
err := SheetCreateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageValidateAcceptsBoundaryDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Boundary values exactly at the lower bound should pass.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
|
||||
"float-image-token": "boxToken", "range": "s1!A1:A1", "float-image-id": "",
|
||||
},
|
||||
map[string]int{"width": 20, "height": 20, "offset-x": 0, "offset-y": 0}, nil)
|
||||
if err := SheetCreateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("boundary values should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"float-image-token": "boxToken", "range": "sheet1!A1:A1", "float-image-id": "",
|
||||
},
|
||||
map[string]int{"width": 200, "height": 150}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"POST"`) {
|
||||
t.Fatalf("DryRun should use POST: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `float_images`) {
|
||||
t.Fatalf("DryRun URL missing float_images: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"float_image_token":"boxToken"`) {
|
||||
t.Fatalf("DryRun missing float_image_token: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"width":200`) || !strings.Contains(got, `"height":150`) {
|
||||
t.Fatalf("DryRun should emit numeric width/height, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{
|
||||
"float_image_id": "fi12345678", "float_image_token": "boxToken",
|
||||
"range": "sheet1!A1:A1", "width": 200, "height": 150,
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--width", "200", "--height", "150",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "float_image_id") {
|
||||
t.Fatalf("stdout missing float_image_id: %s", stdout.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["float_image_token"] != "boxToken" {
|
||||
t.Fatalf("unexpected float_image_token: %v", body["float_image_token"])
|
||||
}
|
||||
if w, ok := body["width"].(float64); !ok || w != 200 {
|
||||
t.Fatalf("width should be numeric 200, got %T=%v", body["width"], body["width"])
|
||||
}
|
||||
if h, ok := body["height"].(float64); !ok || h != 150 {
|
||||
t.Fatalf("height should be numeric 150, got %T=%v", body["height"], body["height"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi12345678"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFloatImageExecuteAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images",
|
||||
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetCreateFloatImage, []string{
|
||||
"+create-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-token", "boxToken",
|
||||
"--range", "sheet1!A1:A1", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsEmptyPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Only IDs set, no mutable field: PATCH would be an empty {} body.
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
}, nil, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "specify at least one of --range") {
|
||||
t.Fatalf("expected empty-payload error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateAcceptsSingleField(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Any single mutable field should satisfy the payload check.
|
||||
tests := []struct {
|
||||
name string
|
||||
strFlags map[string]string
|
||||
intFlags map[string]int
|
||||
}{
|
||||
{
|
||||
name: "range only",
|
||||
strFlags: map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "sheet1!B2:B2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "offset-x only (zero value)",
|
||||
strFlags: map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
},
|
||||
intFlags: map[string]int{"offset-x": 0},
|
||||
},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
|
||||
if err := SheetUpdateFloatImage.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsSheetIDMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "other!A1:A1",
|
||||
"width": "", "height": "", "offset-x": "", "offset-y": "",
|
||||
}, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
|
||||
t.Fatalf("expected sheet-id mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageValidateRejectsOutOfBoundsDims(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
intFlags map[string]int
|
||||
wantSubst string
|
||||
}{
|
||||
{"width below 20", map[string]int{"width": 19}, "--width must be >= 20"},
|
||||
{"height below 20", map[string]int{"height": 0}, "--height must be >= 20"},
|
||||
{"negative offset-x", map[string]int{"offset-x": -10}, "--offset-x must be >= 0"},
|
||||
{"negative offset-y", map[string]int{"offset-y": -1}, "--offset-y must be >= 0"},
|
||||
}
|
||||
|
||||
baseStr := map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi123", "range": "",
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, baseStr, tt.intFlags, nil)
|
||||
err := SheetUpdateFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
"float-image-id": "fi12345678", "range": "sheet1!B2:B2",
|
||||
},
|
||||
map[string]int{"width": 300, "offset-y": 10}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"PATCH"`) {
|
||||
t.Fatalf("DryRun should use PATCH: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fi12345678`) {
|
||||
t.Fatalf("DryRun missing float_image_id: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"width":300`) || !strings.Contains(got, `"offset_y":10`) {
|
||||
t.Fatalf("DryRun should emit numeric width/offset_y, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123", "width": 300},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
|
||||
"+update-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123",
|
||||
"--width", "300", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetUpdateFloatImage, []string{
|
||||
"+update-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123",
|
||||
"--range", "sheet1!C3:C3", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetFloatImage ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetFloatImageValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "float-image-id": "fi1",
|
||||
}, nil)
|
||||
err := SheetGetFloatImage.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetGetFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"GET"`) {
|
||||
t.Fatalf("DryRun should use GET: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `fi123`) {
|
||||
t.Fatalf("DryRun missing float_image_id: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{
|
||||
"float_image_id": "fi123", "range": "sheet1!A1:A1", "width": 100, "height": 100,
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
|
||||
"+get-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fi123") {
|
||||
t.Fatalf("stdout missing fi123: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"float_image": map[string]interface{}{"float_image_id": "fi123"},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetGetFloatImage, []string{
|
||||
"+get-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFloatImages ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestListFloatImagesDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetListFloatImages.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `float_images/query`) {
|
||||
t.Fatalf("DryRun URL missing query: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFloatImagesExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"float_image_id": "fi1"},
|
||||
map[string]interface{}{"float_image_id": "fi2"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFloatImages, []string{
|
||||
"+list-float-images", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "fi1") {
|
||||
t.Fatalf("stdout missing fi1: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFloatImagesWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/query",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetListFloatImages, []string{
|
||||
"+list-float-images", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteFloatImage ────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteFloatImageDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "float-image-id": "fi123",
|
||||
}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteFloatImage.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"DELETE"`) {
|
||||
t.Fatalf("DryRun should use DELETE: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFloatImageExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
|
||||
"+delete-float-image", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFloatImageWithURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/float_images/fi123",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
})
|
||||
err := mountAndRunSheets(t, SheetDeleteFloatImage, []string{
|
||||
"+delete-float-image", "--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--sheet-id", "sheet1", "--float-image-id", "fi123", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
172
shortcuts/sheets/sheet_media_upload.go
Normal file
172
shortcuts/sheets/sheet_media_upload.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// sheetImageParentType is the parent_type accepted by the drive media upload
|
||||
// endpoint for media that will be anchored via +create-float-image.
|
||||
const sheetImageParentType = "sheet_image"
|
||||
|
||||
// SheetMediaUpload uploads a local image to the drive media endpoint against
|
||||
// a spreadsheet and returns the file_token. The token is usable as the
|
||||
// --float-image-token argument to +create-float-image.
|
||||
//
|
||||
// Files up to 20 MB go through /drive/v1/medias/upload_all; larger files are
|
||||
// streamed via upload_prepare / upload_part / upload_finish. This matches the
|
||||
// pattern used by docs +media-upload and drive +import.
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+media-upload",
|
||||
Description: "Upload a local image for use as a floating image and return the file_token",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.Desc("chunked media upload (files > 20MB)").
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry.Set("spreadsheet_token", parentNode)
|
||||
}
|
||||
return dry.Desc("multipart/form-data upload").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("spreadsheet_token", parentNode)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
|
||||
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
fileToken, err := uploadSheetMediaFile(runtime, filePath, fileName, stat.Size(), parentNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"size": stat.Size(),
|
||||
"spreadsheet_token": parentNode,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// resolveSheetMediaUploadParent returns the spreadsheet token to use as parent_node,
|
||||
// accepting either --url or --spreadsheet-token.
|
||||
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != "" {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// uploadSheetMediaFile routes to the single-part or multipart upload path based
|
||||
// on file size. Always uses parent_type=sheet_image so the returned token can
|
||||
// be consumed by +create-float-image.
|
||||
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
pn := parentNode
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
// sheetMediaShouldUseMultipart mirrors docMediaShouldUseMultipart: dry-run uses
|
||||
// local stat as a best-effort planning hint. Execute re-validates before
|
||||
// choosing the actual upload path.
|
||||
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
237
shortcuts/sheets/sheet_media_upload_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSheetMediaUploadValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload", "--file", "img.png", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run should include parent_type=sheet_image, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "upload_prepare") {
|
||||
t.Fatalf("dry-run should not use multipart for small file, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL?sheet=abc",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shtFromURL") {
|
||||
t.Fatalf("dry-run should extract token from URL, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunLargeFileUsesMultipart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
// Sparse file: 20MB + 1 byte, triggers multipart path without allocating disk.
|
||||
largeFile, err := os.Create("big.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := largeFile.Truncate(20*1024*1024 + 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = largeFile.Close()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err = mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "big.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v1/medias/upload_prepare",
|
||||
"/open-apis/drive/v1/medias/upload_part",
|
||||
"/open-apis/drive/v1/medias/upload_finish",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run should include %q for large file, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "upload_all") {
|
||||
t.Fatalf("dry-run should not use upload_all for large file, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "boxTOK123"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "img.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("parse output: %v", err)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data["file_token"] != "boxTOK123" {
|
||||
t.Fatalf("file_token = %v, want boxTOK123", data["file_token"])
|
||||
}
|
||||
if data["spreadsheet_token"] != "shtSTUB" {
|
||||
t.Fatalf("spreadsheet_token = %v, want shtSTUB", data["spreadsheet_token"])
|
||||
}
|
||||
|
||||
body := decodeSheetsMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_type"]; got != sheetImageParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, sheetImageParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "shtSTUB" {
|
||||
t.Fatalf("parent_node = %q, want shtSTUB", got)
|
||||
}
|
||||
if got := body.Fields["file_name"]; got != "img.png" {
|
||||
t.Fatalf("file_name = %q, want img.png", got)
|
||||
}
|
||||
if got := body.Fields["size"]; got != "9" {
|
||||
t.Fatalf("size = %q, want 9 (len of png-bytes)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadFileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "missing.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") {
|
||||
t.Fatalf("err = %v, want file-not-found error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withSheetsTestWorkingDir chdirs to dir for this test. Not compatible with
|
||||
// t.Parallel — chdir is process-wide.
|
||||
func withSheetsTestWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
}
|
||||
|
||||
type capturedSheetsMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeSheetsMultipartBody(t *testing.T, stub *httpmock.Stub) capturedSheetsMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedSheetsMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
_, _ = buf.ReadFrom(part)
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = buf.Bytes()
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = buf.String()
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -51,7 +51,7 @@ var SheetSetStyle = common.Shortcut{
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
return common.NewDryRunAPI().
|
||||
@@ -70,7 +70,7 @@ var SheetSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
|
||||
@@ -40,5 +40,11 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
SheetGetFloatImage,
|
||||
SheetListFloatImages,
|
||||
SheetDeleteFloatImage,
|
||||
}
|
||||
}
|
||||
|
||||
88
sidecar/hmac.go
Normal file
88
sidecar/hmac.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BodySHA256 returns the hex-encoded SHA-256 digest of body.
|
||||
// An empty or nil body produces the SHA-256 of the empty string.
|
||||
func BodySHA256(body []byte) string {
|
||||
h := sha256.Sum256(body)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// CanonicalRequest is the set of fields covered by the HMAC signature.
|
||||
// Clients and servers must populate every field identically for verification
|
||||
// to succeed; any field that is forwarded but *not* covered by this struct can
|
||||
// be tampered with inside the MaxTimestampDrift replay window without
|
||||
// invalidating the signature.
|
||||
//
|
||||
// Version must be set to a known protocol constant (ProtocolV1). It is the
|
||||
// first field in the canonical string so that a future v2 with different
|
||||
// structure cannot be confused for v1 output under the same key.
|
||||
type CanonicalRequest struct {
|
||||
Version string // e.g. ProtocolV1
|
||||
Method string // e.g. "GET", "POST"
|
||||
Host string // e.g. "open.feishu.cn"
|
||||
PathAndQuery string // e.g. "/open-apis/calendar/v4/events?page_size=50"
|
||||
BodySHA256 string // hex-encoded SHA-256 of the request body
|
||||
Timestamp string // Unix epoch seconds string
|
||||
Identity string // IdentityUser or IdentityBot
|
||||
AuthHeader string // header the server should inject the real token into
|
||||
}
|
||||
|
||||
// canonicalString joins the fields with newlines. Field order is part of the
|
||||
// protocol contract — do not reorder without bumping Version.
|
||||
func (c CanonicalRequest) canonicalString() string {
|
||||
return strings.Join([]string{
|
||||
c.Version,
|
||||
c.Method,
|
||||
c.Host,
|
||||
c.PathAndQuery,
|
||||
c.BodySHA256,
|
||||
c.Timestamp,
|
||||
c.Identity,
|
||||
c.AuthHeader,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// Sign computes the HMAC-SHA256 signature over the canonical request string.
|
||||
func Sign(key []byte, req CanonicalRequest) string {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(req.canonicalString()))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify checks that signature matches the HMAC-SHA256 of the canonical
|
||||
// request and that the timestamp is within MaxTimestampDrift seconds of now.
|
||||
// Returns nil on success.
|
||||
func Verify(key []byte, req CanonicalRequest, signature string) error {
|
||||
ts, err := strconv.ParseInt(req.Timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp %q: %w", req.Timestamp, err)
|
||||
}
|
||||
drift := math.Abs(float64(time.Now().Unix() - ts))
|
||||
if drift > MaxTimestampDrift {
|
||||
return fmt.Errorf("timestamp drift %.0fs exceeds limit %ds", drift, MaxTimestampDrift)
|
||||
}
|
||||
expected := Sign(key, req)
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return fmt.Errorf("HMAC signature mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Timestamp returns the current Unix epoch seconds as a string.
|
||||
func Timestamp() string {
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
300
sidecar/hmac_test.go
Normal file
300
sidecar/hmac_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBodySHA256_Empty(t *testing.T) {
|
||||
// SHA-256 of empty string is a well-known constant.
|
||||
want := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
if got := BodySHA256(nil); got != want {
|
||||
t.Errorf("BodySHA256(nil) = %q, want %q", got, want)
|
||||
}
|
||||
if got := BodySHA256([]byte{}); got != want {
|
||||
t.Errorf("BodySHA256([]byte{}) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodySHA256_NonEmpty(t *testing.T) {
|
||||
got := BodySHA256([]byte(`{"key":"value"}`))
|
||||
if len(got) != 64 {
|
||||
t.Errorf("expected 64-char hex string, got %d chars", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// canonical is a test helper that builds a fully-populated CanonicalRequest
|
||||
// with reasonable defaults, so individual tests can override just the field
|
||||
// they want to tamper with.
|
||||
func canonical(override func(*CanonicalRequest)) CanonicalRequest {
|
||||
c := CanonicalRequest{
|
||||
Version: ProtocolV1,
|
||||
Method: "POST",
|
||||
Host: "open.feishu.cn",
|
||||
PathAndQuery: "/open-apis/im/v1/messages?receive_id_type=chat_id",
|
||||
BodySHA256: BodySHA256([]byte(`{"content":"hello"}`)),
|
||||
Timestamp: Timestamp(),
|
||||
Identity: IdentityUser,
|
||||
AuthHeader: "Authorization",
|
||||
}
|
||||
if override != nil {
|
||||
override(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestSignAndVerify(t *testing.T) {
|
||||
key := []byte("test-secret-key-32bytes-long!!!!!")
|
||||
req := canonical(nil)
|
||||
|
||||
sig := Sign(key, req)
|
||||
if len(sig) != 64 {
|
||||
t.Fatalf("signature should be 64-char hex, got %d chars", len(sig))
|
||||
}
|
||||
|
||||
// Valid verification
|
||||
if err := Verify(key, req, sig); err != nil {
|
||||
t.Fatalf("Verify failed for valid signature: %v", err)
|
||||
}
|
||||
|
||||
// Wrong key
|
||||
if err := Verify([]byte("wrong-key"), req, sig); err == nil {
|
||||
t.Error("Verify should fail with wrong key")
|
||||
}
|
||||
|
||||
// Each field must be covered by the signature — tampering with any one
|
||||
// invalidates it.
|
||||
fields := map[string]func(*CanonicalRequest){
|
||||
"version": func(c *CanonicalRequest) { c.Version = "v2" },
|
||||
"method": func(c *CanonicalRequest) { c.Method = "GET" },
|
||||
"host": func(c *CanonicalRequest) { c.Host = "evil.com" },
|
||||
"pathAndQuery": func(c *CanonicalRequest) { c.PathAndQuery = "/steal" },
|
||||
"bodySHA256": func(c *CanonicalRequest) { c.BodySHA256 = BodySHA256([]byte("tampered")) },
|
||||
"identity": func(c *CanonicalRequest) { c.Identity = IdentityBot },
|
||||
"authHeader": func(c *CanonicalRequest) { c.AuthHeader = "Cookie" },
|
||||
}
|
||||
for name, mutate := range fields {
|
||||
t.Run("tamper_"+name, func(t *testing.T) {
|
||||
tampered := canonical(mutate)
|
||||
if err := Verify(key, tampered, sig); err == nil {
|
||||
t.Errorf("Verify should fail when %s is tampered", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_PrivilegeConfusion proves C1: without identity and authHeader in
|
||||
// the canonical string, an attacker holding a captured user-signed request
|
||||
// could replay it as bot (or vice versa) by flipping the header. With both
|
||||
// fields now covered, such a flip must invalidate the signature.
|
||||
func TestVerify_PrivilegeConfusion(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
signed := canonical(func(c *CanonicalRequest) { c.Identity = IdentityUser })
|
||||
sig := Sign(key, signed)
|
||||
|
||||
replayed := signed
|
||||
replayed.Identity = IdentityBot // attacker flips identity
|
||||
if err := Verify(key, replayed, sig); err == nil {
|
||||
t.Error("identity flip must invalidate signature")
|
||||
}
|
||||
|
||||
replayed = signed
|
||||
replayed.AuthHeader = "Cookie" // attacker redirects injection target
|
||||
if err := Verify(key, replayed, sig); err == nil {
|
||||
t.Error("auth-header flip must invalidate signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_TimestampDrift(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
|
||||
// Timestamp too old
|
||||
oldTs := strconv.FormatInt(time.Now().Unix()-MaxTimestampDrift-10, 10)
|
||||
oldReq := canonical(func(c *CanonicalRequest) { c.Timestamp = oldTs })
|
||||
sig := Sign(key, oldReq)
|
||||
if err := Verify(key, oldReq, sig); err == nil {
|
||||
t.Error("Verify should reject expired timestamp")
|
||||
}
|
||||
|
||||
// Timestamp too far in future
|
||||
futureTs := strconv.FormatInt(time.Now().Unix()+MaxTimestampDrift+10, 10)
|
||||
futureReq := canonical(func(c *CanonicalRequest) { c.Timestamp = futureTs })
|
||||
sig = Sign(key, futureReq)
|
||||
if err := Verify(key, futureReq, sig); err == nil {
|
||||
t.Error("Verify should reject future timestamp")
|
||||
}
|
||||
|
||||
// Invalid timestamp
|
||||
badTs := canonical(func(c *CanonicalRequest) { c.Timestamp = "not-a-number" })
|
||||
if err := Verify(key, badTs, "sig"); err == nil {
|
||||
t.Error("Verify should reject invalid timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignDeterministic(t *testing.T) {
|
||||
key := []byte("key")
|
||||
req := canonical(func(c *CanonicalRequest) { c.Timestamp = "12345" })
|
||||
a, b := Sign(key, req), Sign(key, req)
|
||||
if a != b {
|
||||
t.Errorf("Sign should be deterministic: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProxyAddr(t *testing.T) {
|
||||
valid := []string{
|
||||
// loopback IPs
|
||||
"http://127.0.0.1:16384",
|
||||
"127.0.0.1:16384",
|
||||
"[::1]:16384",
|
||||
"http://[::1]:16384",
|
||||
// recognized same-host aliases
|
||||
"http://localhost:8080",
|
||||
"localhost:8080",
|
||||
"http://host.docker.internal:16384",
|
||||
"http://host.containers.internal:16384",
|
||||
"http://host.lima.internal:16384",
|
||||
"http://gateway.docker.internal:16384",
|
||||
// trailing slash is tolerated
|
||||
"http://127.0.0.1:8080/",
|
||||
}
|
||||
for _, addr := range valid {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q) unexpected error: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"foobar",
|
||||
"ftp://127.0.0.1:16384",
|
||||
"http://",
|
||||
"http://127.0.0.1:16384/some/path",
|
||||
":16384",
|
||||
}
|
||||
for _, addr := range invalid {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q) expected error, got nil", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HostConstraint pins C2: the sidecar pattern is
|
||||
// same-machine by definition, so the validator rejects any host that isn't
|
||||
// loopback or a recognized same-host alias. Tampered /etc/hosts is out of
|
||||
// scope (attacker already has ambient host access).
|
||||
func TestValidateProxyAddr_HostConstraint(t *testing.T) {
|
||||
sameHost := []string{
|
||||
"http://127.0.0.1:16384",
|
||||
"http://localhost:8080",
|
||||
"http://host.docker.internal:16384",
|
||||
"http://host.containers.internal:16384",
|
||||
"http://host.lima.internal:16384",
|
||||
"http://gateway.docker.internal:16384",
|
||||
"http://[::1]:16384",
|
||||
// bare form
|
||||
"127.0.0.1:16384",
|
||||
"localhost:8080",
|
||||
"host.docker.internal:16384",
|
||||
}
|
||||
for _, addr := range sameHost {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("expected %q to pass as same-host, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
notSameHost := map[string]string{
|
||||
// The interesting ones — plausible misconfigurations / attacks
|
||||
"public DNS name": "http://attacker.com:8080",
|
||||
"cloud metadata IMDS": "http://169.254.169.254",
|
||||
"private RFC1918": "http://10.0.0.1:16384",
|
||||
"other RFC1918": "http://192.168.1.2:16384",
|
||||
"link-local IPv4": "http://169.254.1.1:16384",
|
||||
"unspecified IPv4 (0.0.0.0)": "http://0.0.0.0:16384",
|
||||
"bare public IP": "http://8.8.8.8:16384",
|
||||
"bare RFC1918": "10.0.0.1:16384",
|
||||
}
|
||||
for name, addr := range notSameHost {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection for %q", addr)
|
||||
}
|
||||
// Error must name the constraint so users know why.
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "loopback") && !strings.Contains(msg, "same-host") {
|
||||
t.Errorf("error should explain same-host requirement, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_RejectsUserinfo closes the URL-phishing vector
|
||||
// http://127.0.0.1@attacker.com (where "127.0.0.1" is actually basic-auth
|
||||
// userinfo and the real host is attacker.com). userinfo has no legitimate
|
||||
// use in the sidecar protocol.
|
||||
func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"http://user@127.0.0.1:16384",
|
||||
"http://user:pass@127.0.0.1:16384",
|
||||
"http://127.0.0.1@attacker.com:16384",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
|
||||
continue
|
||||
}
|
||||
// Either "userinfo" (for addresses parsed with user) or the same-host
|
||||
// message (for e.g. http://127.0.0.1@attacker.com where the REAL
|
||||
// host parses as attacker.com) is acceptable — both reject the
|
||||
// phishing attempt.
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "userinfo") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
|
||||
t.Errorf("error should reject userinfo or flag wrong host, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
|
||||
// rejected explicitly (not lumped into a generic "bad scheme" error) because
|
||||
// the interceptor hardcodes http and would silently downgrade an https URL
|
||||
// otherwise. The message must mention https so users understand why their
|
||||
// perfectly-looking config is refused.
|
||||
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://127.0.0.1:16384",
|
||||
"https://sidecar.corp.internal:443",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "https") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"http://127.0.0.1:16384", "127.0.0.1:16384"},
|
||||
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
|
||||
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
|
||||
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := ProxyHost(tt.input); got != tt.want {
|
||||
t.Errorf("ProxyHost(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
198
sidecar/protocol.go
Normal file
198
sidecar/protocol.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sidecar defines the wire protocol shared between the CLI client
|
||||
// (running inside a sandbox) and the auth sidecar proxy (running in a
|
||||
// trusted environment). Communication uses plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProtocolV1 is the wire-protocol version string embedded in every signed
|
||||
// request. Servers must reject requests whose HeaderProxyVersion is not a
|
||||
// version they understand. Bump this constant (and update the canonical
|
||||
// string) for any breaking change to signing inputs.
|
||||
const ProtocolV1 = "v1"
|
||||
|
||||
// Proxy request headers set by the CLI transport interceptor.
|
||||
const (
|
||||
// HeaderProxyVersion carries the wire-protocol version (e.g. ProtocolV1).
|
||||
// Servers must reject requests whose version they do not understand. The
|
||||
// value is also included in the canonical signing string so that a request
|
||||
// signed for one version cannot be replayed as another.
|
||||
HeaderProxyVersion = "X-Lark-Proxy-Version"
|
||||
|
||||
// HeaderProxyTarget carries the original request host (e.g. "open.feishu.cn").
|
||||
HeaderProxyTarget = "X-Lark-Proxy-Target"
|
||||
|
||||
// HeaderProxyIdentity carries the resolved identity type ("user" or "bot").
|
||||
HeaderProxyIdentity = "X-Lark-Proxy-Identity"
|
||||
|
||||
// HeaderProxySignature carries the HMAC-SHA256 hex signature.
|
||||
HeaderProxySignature = "X-Lark-Proxy-Signature"
|
||||
|
||||
// HeaderProxyTimestamp carries the Unix epoch seconds string used in signing.
|
||||
HeaderProxyTimestamp = "X-Lark-Proxy-Timestamp"
|
||||
|
||||
// HeaderBodySHA256 carries the hex-encoded SHA-256 digest of the request body.
|
||||
HeaderBodySHA256 = "X-Lark-Body-SHA256"
|
||||
|
||||
// HeaderProxyAuthHeader tells the sidecar which header to inject the real
|
||||
// token into. Defaults to "Authorization" for standard OpenAPI requests.
|
||||
// MCP requests use "X-Lark-MCP-UAT" or "X-Lark-MCP-TAT".
|
||||
HeaderProxyAuthHeader = "X-Lark-Proxy-Auth-Header"
|
||||
)
|
||||
|
||||
// MCP auth headers used by the Lark MCP protocol.
|
||||
const (
|
||||
HeaderMCPUAT = "X-Lark-MCP-UAT"
|
||||
HeaderMCPTAT = "X-Lark-MCP-TAT"
|
||||
)
|
||||
|
||||
// Sentinel token values returned by the noop credential provider.
|
||||
// These are placeholder strings that flow through the SDK auth pipeline
|
||||
// but are stripped by the transport interceptor before reaching the sidecar.
|
||||
const (
|
||||
SentinelUAT = "sidecar-managed-uat" // User Access Token placeholder
|
||||
SentinelTAT = "sidecar-managed-tat" // Tenant Access Token placeholder
|
||||
)
|
||||
|
||||
// IdentityUser and IdentityBot are the wire values for HeaderProxyIdentity.
|
||||
const (
|
||||
IdentityUser = "user"
|
||||
IdentityBot = "bot"
|
||||
)
|
||||
|
||||
// MaxTimestampDrift is the maximum allowed difference (in seconds) between
|
||||
// the request timestamp and the server's current time.
|
||||
const MaxTimestampDrift = 60
|
||||
|
||||
// DefaultListenAddr is the default sidecar listen address (localhost only).
|
||||
const DefaultListenAddr = "127.0.0.1:16384"
|
||||
|
||||
// sameHostAliases names DNS aliases commonly used to reach the host running
|
||||
// the sandbox across a container / VM boundary. Traffic to these names stays
|
||||
// on the physical machine (via a virtual bridge), so a plaintext sidecar
|
||||
// channel still satisfies the sidecar pattern's same-host confidentiality
|
||||
// requirement. Adding to this list has real security implications — only add
|
||||
// names that are universally same-host by the runtime's design.
|
||||
var sameHostAliases = map[string]bool{
|
||||
"localhost": true, // universal
|
||||
"host.docker.internal": true, // Docker Desktop (macOS / Windows)
|
||||
"host.containers.internal": true, // Podman Desktop
|
||||
"host.lima.internal": true, // Lima / colima / rancher-desktop
|
||||
"gateway.docker.internal": true, // Docker Desktop alt name
|
||||
}
|
||||
|
||||
// isSameHost returns true when host is either a loopback IP or a recognized
|
||||
// same-host DNS alias. Does not perform DNS resolution — a tampered /etc/hosts
|
||||
// that points an alias elsewhere is out of scope (attacker with that access
|
||||
// already has ambient control of the machine).
|
||||
func isSameHost(host string) bool {
|
||||
if sameHostAliases[host] {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// errNotSameHost is the shared error returned when the sidecar address does
|
||||
// not resolve to the same physical host as the sandbox. Kept in one place so
|
||||
// tests can look for a stable marker.
|
||||
func errNotSameHost(addr string) error {
|
||||
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
|
||||
"(127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
"(localhost, host.docker.internal, host.containers.internal, "+
|
||||
"host.lima.internal, gateway.docker.internal). "+
|
||||
"The sidecar must run on the same physical machine as the sandbox — "+
|
||||
"cross-machine deployment is not a sidecar and is not supported", addr)
|
||||
}
|
||||
|
||||
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
|
||||
// Accepted formats:
|
||||
// - http://host:port
|
||||
// - host:port (bare address, treated as http)
|
||||
//
|
||||
// Host must be loopback or in sameHostAliases. The sidecar pattern is
|
||||
// inherently same-machine; cross-machine deployment is a different product
|
||||
// and is not supported by this feature.
|
||||
//
|
||||
// https:// is rejected because sidecar is a same-host pattern: loopback
|
||||
// and virtual same-host bridges don't traverse any untrusted medium, so
|
||||
// TLS adds no security. Cross-machine deployment is out of scope (see the
|
||||
// host constraint above), so there is no scenario today where https
|
||||
// provides a real benefit over http on loopback.
|
||||
//
|
||||
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
|
||||
// does not use basic auth, and the syntactic slot exists only as a phishing
|
||||
// vector (e.g. http://127.0.0.1@attacker.com).
|
||||
//
|
||||
// Returns an error if the value is not a valid proxy address.
|
||||
func ValidateProxyAddr(addr string) error {
|
||||
if addr == "" {
|
||||
return fmt.Errorf("proxy address is empty")
|
||||
}
|
||||
|
||||
// Bare host:port (no scheme) — validate as a net address.
|
||||
if !strings.Contains(addr, "://") {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
|
||||
}
|
||||
if host == "" || port == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
|
||||
}
|
||||
if !isSameHost(host) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
|
||||
}
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
|
||||
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
|
||||
"no security; cross-machine deployment is out of scope", addr)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: missing host", addr)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
|
||||
}
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
|
||||
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384".
|
||||
// Returns the host:port portion for URL rewriting.
|
||||
func ProxyHost(authProxy string) string {
|
||||
// Strip scheme
|
||||
host := authProxy
|
||||
if i := strings.Index(host, "://"); i >= 0 {
|
||||
host = host[i+3:]
|
||||
}
|
||||
// Strip trailing slash
|
||||
host = strings.TrimRight(host, "/")
|
||||
return host
|
||||
}
|
||||
197
sidecar/server-demo/README.md
Normal file
197
sidecar/server-demo/README.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Sidecar Server Reference Implementation
|
||||
|
||||
> ⚠️ **This is a demo.** For production deployment, implement your own sidecar
|
||||
> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`.
|
||||
|
||||
This example shows how to implement a sidecar auth proxy server that receives
|
||||
HMAC-signed requests from lark-cli sandbox clients and forwards them to the
|
||||
Lark/Feishu API with real credentials injected.
|
||||
|
||||
## What this demo shows
|
||||
|
||||
- HMAC-SHA256 request verification (timestamp drift, body digest, signature)
|
||||
- Target host allowlist + https-only target validation (anti-SSRF / anti-downgrade)
|
||||
- Identity-based token resolution (UAT for user, TAT for bot)
|
||||
- Auth-header allowlist: real token may only be injected into `Authorization`
|
||||
/ `X-Lark-MCP-UAT` / `X-Lark-MCP-TAT`, rejecting attempts to smuggle it into
|
||||
`Cookie`, `User-Agent`, or other intermediate-logged headers
|
||||
- Audit logging with path ID-segment sanitization and upstream error truncation
|
||||
- Safe request forwarding (strips client-supplied auth headers)
|
||||
|
||||
## What this demo does NOT handle
|
||||
|
||||
- **TAT refresh** — the shared `DefaultTokenProvider` caches the TAT via
|
||||
`sync.Once`, which never refreshes. A long-running server will return an
|
||||
expired TAT after 2 hours. Production implementations should maintain a
|
||||
TTL-based cache with early renewal.
|
||||
- High availability / load balancing / hot key rotation
|
||||
- TLS termination
|
||||
- Rate limiting / per-identity quotas
|
||||
|
||||
## Both sides need the right build tags
|
||||
|
||||
Sidecar is split into **two separate binaries** with **different build tags**:
|
||||
|
||||
| Side | Binary | Build tag | How to build |
|
||||
| --- | --- | --- | --- |
|
||||
| Sandbox (client) | `lark-cli` | `authsidecar` | `go build -tags authsidecar -o lark-cli .` |
|
||||
| Trusted (server) | `sidecar-server-demo` | `authsidecar_demo` | `go build -tags authsidecar_demo -o sidecar-server-demo ./sidecar/server-demo/` |
|
||||
|
||||
If the sandbox runs a standard `lark-cli` **without** `-tags authsidecar`, the
|
||||
`LARKSUITE_CLI_AUTH_PROXY` env var is ignored and requests bypass the sidecar
|
||||
entirely — real credentials (if any) leak to the sandbox.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The demo reuses the lark-cli credential pipeline, so the trusted machine must
|
||||
have an app configured:
|
||||
|
||||
```bash
|
||||
lark-cli config init --new # configure app_id / app_secret (required)
|
||||
lark-cli auth login # store user refresh_token in keychain
|
||||
# (only required if sandbox will use --as user)
|
||||
```
|
||||
|
||||
`auth login` is **only required for user identity**. If the server will only
|
||||
serve bot requests (TAT), `config init` alone is enough because the TAT is
|
||||
minted from `app_id + app_secret`.
|
||||
|
||||
Also, the server process **must not** inherit `LARKSUITE_CLI_AUTH_PROXY` — if
|
||||
it does, the sidecar credential provider would activate inside the server and
|
||||
return sentinel tokens instead of real ones. The demo rejects this at startup
|
||||
with a clear error, but you should make sure to `unset LARKSUITE_CLI_AUTH_PROXY`
|
||||
in the server shell before launching.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./sidecar-server-demo \
|
||||
--listen 127.0.0.1:16384 \
|
||||
--key-file <HOME>/.lark-sidecar/proxy.key \
|
||||
--log-file <HOME>/.lark-sidecar/audit.log
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
|
||||
| `--key-file` | `<HOME>/.lark-sidecar/proxy.key` | Path to write the generated HMAC key (mode 0600) |
|
||||
| `--log-file` | *(empty, stderr)* | Audit log output path |
|
||||
| `--profile` | *(empty, active profile)* | lark-cli profile name for credential lookup |
|
||||
|
||||
### Startup output
|
||||
|
||||
```
|
||||
Auth sidecar listening on http://127.0.0.1:16384
|
||||
HMAC key prefix: a3b2c1d4
|
||||
Full key written to /Users/alice/.lark-sidecar/proxy.key (mode 0600)
|
||||
|
||||
Set in sandbox:
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
|
||||
export LARKSUITE_CLI_PROXY_KEY="<read from /Users/alice/.lark-sidecar/proxy.key>"
|
||||
export LARKSUITE_CLI_APP_ID="cli_xxx"
|
||||
export LARKSUITE_CLI_BRAND="feishu"
|
||||
```
|
||||
|
||||
The `key-file` path is printed exactly as passed on the command line (relative
|
||||
paths stay relative). The `HMAC key prefix` is the first 8 characters for
|
||||
identification without revealing the full key.
|
||||
|
||||
### Sandbox env vars (complete list)
|
||||
|
||||
The startup banner only prints the *required* variables. Two more are
|
||||
optional:
|
||||
|
||||
```bash
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://..." # required (see constraints below)
|
||||
export LARKSUITE_CLI_PROXY_KEY="..." # required
|
||||
export LARKSUITE_CLI_APP_ID="cli_xxx" # required
|
||||
export LARKSUITE_CLI_BRAND="feishu" # required (feishu | lark)
|
||||
export LARKSUITE_CLI_DEFAULT_AS="user" # optional: force default identity
|
||||
export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to one identity
|
||||
```
|
||||
|
||||
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
|
||||
|
||||
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
|
||||
today because the interceptor does not yet perform TLS; a future PR that
|
||||
wires up real TLS will relax this.
|
||||
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
|
||||
same-host aliases: `localhost`, `host.docker.internal`,
|
||||
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
|
||||
The sidecar pattern is inherently same-machine; cross-machine deployment
|
||||
is a different product (auth broker / STS) with different security
|
||||
requirements (mTLS, cert rotation, per-client keys) and is not supported
|
||||
by this feature.
|
||||
- No path, query, fragment, or `user:pass@` in the URL.
|
||||
|
||||
**How auto identity detection works in sidecar mode**: on every invocation the
|
||||
CLI asks the sidecar to look up the logged-in user's `open_id` via
|
||||
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
|
||||
if it fails (trusted side has no valid user login, or the call errors out),
|
||||
it falls back to `bot`. Setting `LARKSUITE_CLI_DEFAULT_AS=user` lets you
|
||||
short-circuit this and always default to user regardless of the lookup
|
||||
result; set it to `bot` for the opposite.
|
||||
|
||||
**Note**: `LARKSUITE_CLI_STRICT_MODE` and the server's identity allowlist are
|
||||
two separate enforcement points:
|
||||
- `STRICT_MODE` is interpreted locally by the sandbox CLI — it rejects
|
||||
`--as` values the sandbox itself disallows, before any request goes out.
|
||||
- The server's allowlist is built from the **trusted-side** config's
|
||||
`SupportedIdentities` (`sidecar/server-demo/allowlist.go`). The sandbox
|
||||
cannot override it.
|
||||
|
||||
A well-configured deployment aligns both (e.g. both set to `user` when the
|
||||
app only supports user tokens), but they are computed independently.
|
||||
|
||||
### Graceful shutdown
|
||||
|
||||
Send `SIGINT` (`Ctrl+C`) or `SIGTERM` to stop the server. The demo drains
|
||||
in-flight requests with a 5-second timeout before exiting.
|
||||
|
||||
## Wire protocol
|
||||
|
||||
See the [`sidecar` package on pkg.go.dev](https://pkg.go.dev/github.com/larksuite/cli/sidecar)
|
||||
for protocol constants, HMAC signing/verification, and address validation utilities.
|
||||
|
||||
Headers (client → server):
|
||||
|
||||
| Header | Purpose |
|
||||
| --- | --- |
|
||||
| `X-Lark-Proxy-Version` | Wire-protocol version (currently `"v1"`). Server rejects unknown values with 400. |
|
||||
| `X-Lark-Proxy-Target` | Original target **scheme + host only** (e.g. `https://open.feishu.cn`). Must be `https://`; any path/query/fragment/userinfo in this header is rejected. The path and query come from the request line itself; the server reconstructs the upstream URL as `https://<host> + requestURI`. |
|
||||
| `X-Lark-Proxy-Identity` | `"user"` or `"bot"`. Covered by the signature. |
|
||||
| `X-Lark-Proxy-Auth-Header` | Which header the server should inject real token into. Covered by the signature. |
|
||||
| `X-Lark-Proxy-Signature` | hex-encoded HMAC-SHA256 |
|
||||
| `X-Lark-Proxy-Timestamp` | Unix seconds (drift ≤ 60s) |
|
||||
| `X-Lark-Body-SHA256` | hex-encoded SHA-256 of the request body |
|
||||
|
||||
Signing material (newline-separated, in order):
|
||||
|
||||
```text
|
||||
version
|
||||
method
|
||||
host
|
||||
pathAndQuery
|
||||
bodySHA256
|
||||
timestamp
|
||||
identity
|
||||
authHeader
|
||||
```
|
||||
|
||||
Every field above is part of the canonical string. In particular, `identity`
|
||||
and `authHeader` are covered so a captured request cannot be replayed with
|
||||
its identity flipped (bot↔user) or its auth-header redirected (e.g. into
|
||||
`Cookie`) inside the 60s drift window.
|
||||
|
||||
## Source layout
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `main.go` | Entry point: flag parsing, server lifecycle |
|
||||
| `handler.go` | `proxyHandler.ServeHTTP` — main request flow |
|
||||
| `forward.go` | Forwarding HTTP client + proxy-header filter |
|
||||
| `allowlist.go` | Target host / identity allowlists |
|
||||
| `audit.go` | Log path/error sanitization |
|
||||
| `handler_test.go` | Unit tests for all of the above |
|
||||
44
sidecar/server-demo/allowlist.go
Normal file
44
sidecar/server-demo/allowlist.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// buildAllowedHosts extracts the set of allowed target hostnames from
|
||||
// multiple brand endpoints so the sidecar can serve both feishu and lark clients.
|
||||
func buildAllowedHosts(endpoints ...core.Endpoints) map[string]bool {
|
||||
hosts := make(map[string]bool)
|
||||
for _, ep := range endpoints {
|
||||
for _, u := range []string{ep.Open, ep.Accounts, ep.MCP} {
|
||||
if idx := strings.Index(u, "://"); idx >= 0 {
|
||||
hosts[u[idx+3:]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// buildAllowedIdentities returns the set of identities the sidecar is allowed to serve,
|
||||
// based on the trusted-side strict mode / SupportedIdentities configuration.
|
||||
func buildAllowedIdentities(cfg *core.CliConfig) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
switch {
|
||||
case cfg.SupportedIdentities == 0: // unknown/unset → allow both
|
||||
ids[sidecar.IdentityUser] = true
|
||||
ids[sidecar.IdentityBot] = true
|
||||
case cfg.SupportedIdentities&1 != 0: // SupportsUser bit
|
||||
ids[sidecar.IdentityUser] = true
|
||||
}
|
||||
if cfg.SupportedIdentities == 0 || cfg.SupportedIdentities&2 != 0 { // SupportsBot bit
|
||||
ids[sidecar.IdentityBot] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
51
sidecar/server-demo/audit.go
Normal file
51
sidecar/server-demo/audit.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizePath strips query parameters and replaces ID-like path segments
|
||||
// with ":id" to prevent document tokens, chat IDs, etc. from leaking into logs.
|
||||
// Example: /open-apis/docx/v1/documents/doxcnXXXX/blocks → /open-apis/docx/v1/documents/:id/blocks
|
||||
func sanitizePath(pathAndQuery string) string {
|
||||
// Strip query
|
||||
path := pathAndQuery
|
||||
if i := strings.IndexByte(path, '?'); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
// Replace ID-like segments (8+ chars, not a pure API keyword)
|
||||
parts := strings.Split(path, "/")
|
||||
for i, p := range parts {
|
||||
if looksLikeID(p) {
|
||||
parts[i] = ":id"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// looksLikeID returns true if a path segment appears to be a resource identifier
|
||||
// rather than an API route keyword. Heuristic: 8+ chars and contains a digit.
|
||||
func looksLikeID(seg string) bool {
|
||||
if len(seg) < 8 {
|
||||
return false
|
||||
}
|
||||
for _, c := range seg {
|
||||
if c >= '0' && c <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sanitizeError returns a safe error string for logging, capped at 200 bytes
|
||||
// to avoid dumping upstream response bodies into audit logs.
|
||||
func sanitizeError(err error) string {
|
||||
s := err.Error()
|
||||
if len(s) > 200 {
|
||||
return s[:200] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
51
sidecar/server-demo/forward.go
Normal file
51
sidecar/server-demo/forward.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// newForwardClient creates an HTTP client for forwarding requests to the
|
||||
// Lark API. It strips Authorization on cross-host redirects and disables
|
||||
// proxy to prevent real tokens from leaking through environment proxies.
|
||||
func newForwardClient() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil // never proxy the trusted hop
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(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(sidecar.HeaderMCPUAT)
|
||||
req.Header.Del(sidecar.HeaderMCPTAT)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// isProxyHeader returns true for headers specific to the sidecar protocol.
|
||||
func isProxyHeader(key string) bool {
|
||||
switch http.CanonicalHeaderKey(key) {
|
||||
case http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
|
||||
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
271
sidecar/server-demo/handler.go
Normal file
271
sidecar/server-demo/handler.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// proxyHandler handles HTTP requests from sandbox CLI instances.
|
||||
type proxyHandler struct {
|
||||
key []byte
|
||||
cred *credential.CredentialProvider
|
||||
appID string
|
||||
brand core.LarkBrand
|
||||
logger *log.Logger
|
||||
forwardCl *http.Client
|
||||
allowedHosts map[string]bool // target host allowlist derived from brand
|
||||
allowedIDs map[string]bool // identity allowlist derived from strict mode
|
||||
}
|
||||
|
||||
// allowedAuthHeaders lists the only header names the sidecar will inject real
|
||||
// tokens into. Limiting this prevents a compromised sandbox from signing a
|
||||
// request with X-Lark-Proxy-Auth-Header: Cookie (or User-Agent /
|
||||
// X-Forwarded-For / any X-* header) and having the real token smuggled into
|
||||
// an upstream header that Lark ignores for auth but intermediate logs may
|
||||
// capture — an indirect exfiltration path.
|
||||
//
|
||||
// These three are the only values the CLI interceptor ever emits
|
||||
// (Authorization for OpenAPI, MCP-UAT/TAT for the MCP protocol), so anything
|
||||
// else is by definition a misuse.
|
||||
var allowedAuthHeaders = map[string]bool{
|
||||
"Authorization": true,
|
||||
sidecar.HeaderMCPUAT: true, // X-Lark-MCP-UAT
|
||||
sidecar.HeaderMCPTAT: true, // X-Lark-MCP-TAT
|
||||
}
|
||||
|
||||
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// 0. Check protocol version. We reject rather than default so that an
|
||||
// old client paired with a newer server (or vice versa) fails loudly
|
||||
// instead of silently producing mismatched signatures.
|
||||
version := r.Header.Get(sidecar.HeaderProxyVersion)
|
||||
if version != sidecar.ProtocolV1 {
|
||||
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Verify timestamp
|
||||
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
|
||||
if ts == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Read body and verify SHA256
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
|
||||
if claimedSHA == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderBodySHA256, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
actualSHA := sidecar.BodySHA256(body)
|
||||
if claimedSHA != actualSHA {
|
||||
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Verify HMAC signature
|
||||
//Enforce scheme=https and reject any path/query embedded in the target.
|
||||
// The sandbox is untrusted: without this check it could send
|
||||
// X-Lark-Proxy-Target: http://open.feishu.cn to force the injected real
|
||||
// token out over cleartext HTTP, exposing it to any on-path attacker
|
||||
// between the sidecar and upstream.
|
||||
target := r.Header.Get(sidecar.HeaderProxyTarget)
|
||||
if target == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyTarget, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pathAndQuery := r.URL.RequestURI()
|
||||
targetHost, err := parseTarget(target)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Identity and auth-header must be read before HMAC verification because
|
||||
// both are covered by the canonical signing string. Defaulting either one
|
||||
// server-side would let an attacker flip the injected token's identity or
|
||||
// target header within the replay window without invalidating the sig.
|
||||
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
|
||||
if identity == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyIdentity, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
authHeader := r.Header.Get(sidecar.HeaderProxyAuthHeader)
|
||||
if authHeader == "" {
|
||||
http.Error(w, "missing "+sidecar.HeaderProxyAuthHeader, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
signature := r.Header.Get(sidecar.HeaderProxySignature)
|
||||
if err := sidecar.Verify(h.key, sidecar.CanonicalRequest{
|
||||
Version: version,
|
||||
Method: r.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: claimedSHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
}, signature); err != nil {
|
||||
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Validate target host against allowlist
|
||||
if !h.allowedHosts[targetHost] {
|
||||
http.Error(w, "target host not allowed: "+targetHost, http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=\"target host %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), targetHost)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Validate identity
|
||||
if !h.allowedIDs[identity] {
|
||||
http.Error(w, "identity not allowed: "+identity, http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=\"identity %s not allowed by strict mode\"", r.Method, sanitizePath(pathAndQuery), identity)
|
||||
return
|
||||
}
|
||||
|
||||
// 5.5 Validate auth-header (required — the client controls this value,
|
||||
// and without an allowlist a compromised sandbox could direct the real
|
||||
// token into arbitrary forwarded headers).
|
||||
if !allowedAuthHeaders[authHeader] {
|
||||
http.Error(w, "auth-header not allowed: "+authHeader, http.StatusForbidden)
|
||||
h.logger.Printf("REJECT method=%s path=%s reason=\"auth-header %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), authHeader)
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Resolve real token
|
||||
var tokenType credential.TokenType
|
||||
switch identity {
|
||||
case sidecar.IdentityUser:
|
||||
tokenType = credential.TokenTypeUAT
|
||||
default:
|
||||
tokenType = credential.TokenTypeTAT
|
||||
}
|
||||
|
||||
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
|
||||
Type: tokenType,
|
||||
AppID: h.appID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve token: "+err.Error(), http.StatusInternalServerError)
|
||||
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s error=%q", r.Method, sanitizePath(pathAndQuery), identity, sanitizeError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Build forwarding request. Scheme is pinned to https here (not taken
|
||||
// from the client-supplied target) so any future change to parseTarget
|
||||
// cannot regress the cleartext-leak protection.
|
||||
forwardURL := "https://" + targetHost + pathAndQuery
|
||||
forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create forward request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy non-proxy headers
|
||||
for k, vs := range r.Header {
|
||||
if isProxyHeader(k) {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
forwardReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Strip any client-supplied auth headers. The sidecar is the sole source
|
||||
// of authentication material on the forwarded request; a client could
|
||||
// otherwise smuggle an extra Authorization/MCP token alongside the one
|
||||
// the sidecar injects below.
|
||||
forwardReq.Header.Del("Authorization")
|
||||
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
|
||||
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
// 8. Inject real token into the header the client committed to in the
|
||||
// signature. Standard OpenAPI uses "Authorization: Bearer <token>"; MCP
|
||||
// uses "X-Lark-MCP-UAT: <token>" or "X-Lark-MCP-TAT: <token>".
|
||||
if authHeader == "Authorization" {
|
||||
forwardReq.Header.Set("Authorization", "Bearer "+tokenResult.Token)
|
||||
} else {
|
||||
forwardReq.Header.Set(authHeader, tokenResult.Token)
|
||||
}
|
||||
|
||||
// 9. Forward request
|
||||
resp, err := h.forwardCl.Do(forwardReq)
|
||||
if err != nil {
|
||||
http.Error(w, "forward request failed: "+err.Error(), http.StatusBadGateway)
|
||||
h.logger.Printf("FORWARD_ERROR method=%s path=%s error=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 10. Copy response back
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
|
||||
// 11. Audit log
|
||||
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s",
|
||||
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// parseTarget validates X-Lark-Proxy-Target and returns the host portion for
|
||||
// HMAC input and allowlist lookup. The target must be "https://<host>" with no
|
||||
// path, query, fragment, userinfo, or non-https scheme. Rejecting these shapes
|
||||
// closes a token-leak channel: a compromised sandbox holding PROXY_KEY could
|
||||
// otherwise request cleartext HTTP forwarding (or inject a path to a different
|
||||
// endpoint than the allowlist entry implies).
|
||||
func parseTarget(target string) (host string, err error) {
|
||||
u, perr := url.Parse(target)
|
||||
if perr != nil {
|
||||
return "", fmt.Errorf("parse: %w", perr)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("missing host")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("userinfo not allowed")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return "", fmt.Errorf("query not allowed")
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return "", fmt.Errorf("fragment not allowed")
|
||||
}
|
||||
return u.Host, nil
|
||||
}
|
||||
670
sidecar/server-demo/handler_test.go
Normal file
670
sidecar/server-demo/handler_test.go
Normal file
@@ -0,0 +1,670 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token.
|
||||
type fakeExtProvider struct {
|
||||
token string
|
||||
}
|
||||
|
||||
func (f *fakeExtProvider) Name() string { return "fake" }
|
||||
func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return &extcred.Token{Value: f.token, Source: "fake"}, nil
|
||||
}
|
||||
|
||||
func discardLogger() *log.Logger {
|
||||
return log.New(io.Discard, "", 0)
|
||||
}
|
||||
|
||||
func newTestHandler(key []byte) *proxyHandler {
|
||||
return &proxyHandler{
|
||||
key: key,
|
||||
logger: discardLogger(),
|
||||
forwardCl: &http.Client{},
|
||||
allowedHosts: map[string]bool{
|
||||
"open.feishu.cn": true,
|
||||
"accounts.feishu.cn": true,
|
||||
"mcp.feishu.cn": true,
|
||||
},
|
||||
allowedIDs: map[string]bool{
|
||||
sidecar.IdentityUser: true,
|
||||
sidecar.IdentityBot: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// signedReq creates a properly signed request for testing handler logic past
|
||||
// HMAC verification. Identity defaults to bot and auth-header to
|
||||
// "Authorization"; callers can override by mutating the returned request
|
||||
// before calling ServeHTTP (and re-signing if they need the signature to
|
||||
// remain valid after the mutation).
|
||||
func signedReq(t *testing.T, key []byte, method, target, path string, body []byte) *http.Request {
|
||||
t.Helper()
|
||||
targetHost := target
|
||||
if idx := strings.Index(target, "://"); idx >= 0 {
|
||||
targetHost = target[idx+3:]
|
||||
}
|
||||
bodySHA := sidecar.BodySHA256(body)
|
||||
ts := sidecar.Timestamp()
|
||||
identity := sidecar.IdentityBot
|
||||
authHeader := "Authorization"
|
||||
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: path,
|
||||
BodySHA256: bodySHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
})
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bodyReader)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, target)
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
return req
|
||||
}
|
||||
|
||||
// resign recomputes the HMAC signature over the request's current proxy
|
||||
// headers. Use this in tests that mutate a signed field (Identity,
|
||||
// AuthHeader, Target host, etc.) after calling signedReq.
|
||||
func resign(t *testing.T, key []byte, req *http.Request, body []byte) {
|
||||
t.Helper()
|
||||
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
||||
targetHost := target
|
||||
if idx := strings.Index(target, "://"); idx >= 0 {
|
||||
targetHost = target[idx+3:]
|
||||
}
|
||||
sig := sidecar.Sign(key, sidecar.CanonicalRequest{
|
||||
Version: req.Header.Get(sidecar.HeaderProxyVersion),
|
||||
Method: req.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: req.URL.RequestURI(),
|
||||
BodySHA256: sidecar.BodySHA256(body),
|
||||
Timestamp: req.Header.Get(sidecar.HeaderProxyTimestamp),
|
||||
Identity: req.Header.Get(sidecar.HeaderProxyIdentity),
|
||||
AuthHeader: req.Header.Get(sidecar.HeaderProxyAuthHeader),
|
||||
})
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
}
|
||||
|
||||
// TestProxyHandler_UnsupportedVersion verifies the handler rejects requests
|
||||
// whose HeaderProxyVersion is absent or set to an unknown value. Kept in
|
||||
// front so an old client paired with a newer server (or vice versa) surfaces
|
||||
// a clear 400 instead of a misleading HMAC mismatch downstream.
|
||||
func TestProxyHandler_UnsupportedVersion(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
for _, v := range []string{"", "v0", "v2"} {
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
if v != "" {
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("version=%q: expected 400, got %d", v, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_MissingTimestamp(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_MissingBodySHA(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_BadHMAC(t *testing.T) {
|
||||
h := newTestHandler([]byte("real-key"))
|
||||
|
||||
bodySHA := sidecar.BodySHA256(nil)
|
||||
ts := sidecar.Timestamp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/path", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityBot)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Authorization")
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, "bad-signature")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_BodySHA256Mismatch(t *testing.T) {
|
||||
h := newTestHandler([]byte("key"))
|
||||
|
||||
req := httptest.NewRequest("POST", "/path", bytes.NewReader([]byte("real body")))
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn")
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp())
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, sidecar.BodySHA256([]byte("different body")))
|
||||
req.Header.Set(sidecar.HeaderProxySignature, "whatever")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_TargetNotAllowed(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
req := signedReq(t, key, "GET", "https://evil.com", "/steal", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for disallowed host, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHandler_IdentityNotAllowed(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
// Restrict to bot only
|
||||
h.allowedIDs = map[string]bool{sidecar.IdentityBot: true}
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
|
||||
resign(t, key, req, nil) // identity is signed; must re-sign after mutation
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for disallowed identity, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTarget covers the per-shape rejections directly, without the
|
||||
// surrounding HTTP plumbing.
|
||||
func TestParseTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
wantErr bool
|
||||
wantSub string // expected fragment of the error message
|
||||
}{
|
||||
{name: "valid https", target: "https://open.feishu.cn", wantErr: false},
|
||||
{name: "valid https trailing slash", target: "https://open.feishu.cn/", wantErr: false},
|
||||
{name: "http downgrade", target: "http://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "missing scheme", target: "open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "ftp scheme", target: "ftp://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "empty", target: "", wantErr: true, wantSub: "scheme must be https"},
|
||||
{name: "empty host", target: "https://", wantErr: true, wantSub: "missing host"},
|
||||
{name: "with path", target: "https://open.feishu.cn/open-apis", wantErr: true, wantSub: "path not allowed"},
|
||||
{name: "with query", target: "https://open.feishu.cn?a=1", wantErr: true, wantSub: "query not allowed"},
|
||||
{name: "with fragment", target: "https://open.feishu.cn#frag", wantErr: true, wantSub: "fragment not allowed"},
|
||||
{name: "with userinfo", target: "https://attacker:pw@open.feishu.cn", wantErr: true, wantSub: "userinfo not allowed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host, err := parseTarget(tc.target)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got host=%q", host)
|
||||
}
|
||||
if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tc.wantSub)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != "open.feishu.cn" {
|
||||
t.Errorf("host = %q, want %q", host, "open.feishu.cn")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsNonHTTPSTarget verifies end-to-end that a
|
||||
// compromised sandbox holding a valid PROXY_KEY cannot coerce the sidecar
|
||||
// into forwarding real tokens over cleartext HTTP or to an unexpected path.
|
||||
// The check must fire before HMAC verification so that the request is
|
||||
// rejected even when the signature is technically valid.
|
||||
func TestProxyHandler_RejectsNonHTTPSTarget(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
}{
|
||||
{"http downgrade", "http://open.feishu.cn"},
|
||||
{"bare hostname", "open.feishu.cn"},
|
||||
{"ftp scheme", "ftp://open.feishu.cn"},
|
||||
{"target with path", "https://open.feishu.cn/open-apis/evil"},
|
||||
{"target with query", "https://open.feishu.cn?steal=1"},
|
||||
{"target with userinfo", "https://attacker:pw@open.feishu.cn"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Sign with a valid key against the malicious target — proves the
|
||||
// scheme/shape check is not bypassed by signature legitimacy.
|
||||
req := signedReq(t, key, "GET", tc.target, "/open-apis/im/v1/chats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for target %q, got %d (body: %s)", tc.target, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsIdentityReplay locks in C1 end-to-end: a captured
|
||||
// bot-signed request whose identity header is flipped to user (or vice versa)
|
||||
// must be rejected at HMAC verification, not silently served with the wrong
|
||||
// token type. Without identity in the canonical string this returns 200.
|
||||
func TestProxyHandler_RejectsIdentityReplay(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
// Attacker flips identity without touching signature.
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("identity replay must fail signature verify (got %d, want 401): %s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsAuthHeaderReplay is the companion: flipping
|
||||
// X-Lark-Proxy-Auth-Header post-signature must invalidate the signature so
|
||||
// an attacker cannot redirect the injected token into an unintended header.
|
||||
func TestProxyHandler_RejectsAuthHeaderReplay(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, "Cookie")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("auth-header replay must fail signature verify (got %d, want 401): %s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_RejectsAuthHeaderNotInAllowlist pins the auth-header
|
||||
// allowlist: even a correctly-signed request must be rejected if it asks
|
||||
// the sidecar to inject the real token into an unintended header (e.g.
|
||||
// Cookie / User-Agent / X-Forwarded-For). This closes the sidechannel
|
||||
// where the real token ends up in headers that Lark ignores for auth but
|
||||
// intermediate logs may capture.
|
||||
func TestProxyHandler_RejectsAuthHeaderNotInAllowlist(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
h := newTestHandler(key)
|
||||
|
||||
for _, bad := range []string{"Cookie", "User-Agent", "X-Forwarded-For", "X-Real-IP", "Set-Cookie"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, bad)
|
||||
resign(t, key, req, nil) // auth-header is signed; must re-sign after override
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("authHeader=%q: expected 403, got %d (body: %s)",
|
||||
bad, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyHandler_AcceptsAllowedAuthHeaders confirms the three protocol
|
||||
// header names remain accepted after the allowlist is enforced. Uses
|
||||
// newTestHandler which has no upstream forwarding set up, so reaching the
|
||||
// forward step is proof the auth-header check passed.
|
||||
func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
|
||||
for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} {
|
||||
t.Run(good, func(t *testing.T) {
|
||||
// Use a handler with a real (fake) credential provider so we can
|
||||
// distinguish auth-header reject (403) from later failures.
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
h := &proxyHandler{
|
||||
key: key,
|
||||
cred: cred,
|
||||
appID: "cli_test",
|
||||
logger: discardLogger(),
|
||||
forwardCl: &http.Client{},
|
||||
allowedHosts: map[string]bool{"open.feishu.cn": true},
|
||||
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
|
||||
}
|
||||
|
||||
req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, good)
|
||||
resign(t, key, req, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
// Expect NOT 403 "auth-header not allowed" — the request will fail
|
||||
// at forward (502 because open.feishu.cn isn't reachable without
|
||||
// an actual upstream in tests), but it must get past our check.
|
||||
if w.Code == http.StatusForbidden && strings.Contains(w.Body.String(), "auth-header not allowed") {
|
||||
t.Errorf("authHeader=%q was rejected by allowlist: %s", good, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RejectsSelfProxy(t *testing.T) {
|
||||
old, had := os.LookupEnv(envvars.CliAuthProxy)
|
||||
os.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
defer func() {
|
||||
if had {
|
||||
os.Setenv(envvars.CliAuthProxy, old)
|
||||
} else {
|
||||
os.Unsetenv(envvars.CliAuthProxy)
|
||||
}
|
||||
}()
|
||||
|
||||
err := run(context.Background(), "127.0.0.1:0", "/tmp/should-not-be-created.key", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when AUTH_PROXY is set")
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAuthProxy) {
|
||||
t.Errorf("error should mention %s, got: %v", envvars.CliAuthProxy, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardClient_RedirectStripsAuth(t *testing.T) {
|
||||
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("Authorization leaked to redirect target: %s", auth)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer redirectTarget.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
client := newForwardClient()
|
||||
req, _ := http.NewRequest("GET", origin.URL+"/start", nil)
|
||||
req.Header.Set("Authorization", "Bearer real-token")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestForwardClient_RedirectStripsMCPHeaders(t *testing.T) {
|
||||
redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if v := r.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Errorf("X-Lark-MCP-UAT leaked to redirect target: %s", v)
|
||||
}
|
||||
if v := r.Header.Get(sidecar.HeaderMCPTAT); v != "" {
|
||||
t.Errorf("X-Lark-MCP-TAT leaked to redirect target: %s", v)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer redirectTarget.Close()
|
||||
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
client := newForwardClient()
|
||||
req, _ := http.NewRequest("POST", origin.URL+"/mcp", nil)
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, "real-uat-token")
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, "real-tat-token")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// TestProxyHandler_StripsClientSuppliedAuthHeaders verifies that the sidecar
|
||||
// is the sole source of auth headers on the forwarded request. A malicious
|
||||
// sandbox client must not be able to smuggle an Authorization/MCP header that
|
||||
// rides along with the sidecar-injected real token.
|
||||
func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
|
||||
const realToken = "real-tenant-access-token"
|
||||
|
||||
// Capture what the upstream receives after sidecar forwarding.
|
||||
// TLS is required because parseTarget rejects non-https targets.
|
||||
var captured http.Header
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
// Strip "https://" prefix to get host:port (matches what the handler sees).
|
||||
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
|
||||
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{token: realToken}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
key := []byte("test-key")
|
||||
h := &proxyHandler{
|
||||
key: key,
|
||||
cred: cred,
|
||||
appID: "cli_test",
|
||||
logger: discardLogger(),
|
||||
forwardCl: upstream.Client(), // trusts the httptest CA
|
||||
allowedHosts: map[string]bool{upstreamHost: true},
|
||||
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
proxyAuthHeader string // which header sidecar should inject into
|
||||
wantInjectedHeader string // the header the real token ends up in
|
||||
wantInjectedValue string
|
||||
wantStrippedHeaders []string
|
||||
}{
|
||||
{
|
||||
name: "inject Authorization, strip MCP attacker headers",
|
||||
proxyAuthHeader: "Authorization",
|
||||
wantInjectedHeader: "Authorization",
|
||||
wantInjectedValue: "Bearer " + realToken,
|
||||
wantStrippedHeaders: []string{sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT},
|
||||
},
|
||||
{
|
||||
name: "inject MCP UAT, strip Authorization attacker header",
|
||||
proxyAuthHeader: sidecar.HeaderMCPUAT,
|
||||
wantInjectedHeader: sidecar.HeaderMCPUAT,
|
||||
wantInjectedValue: realToken,
|
||||
wantStrippedHeaders: []string{"Authorization", sidecar.HeaderMCPTAT},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
captured = nil
|
||||
|
||||
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, tc.proxyAuthHeader)
|
||||
resign(t, key, req, nil) // auth-header is signed; re-sign after override
|
||||
|
||||
// Attacker smuggles all three possible auth headers with bogus values.
|
||||
req.Header.Set("Authorization", "Bearer attacker-token")
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, "attacker-uat")
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, "attacker-tat")
|
||||
|
||||
// Non-auth headers should still pass through.
|
||||
req.Header.Set("X-Custom-Header", "keep-me")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 from upstream, got %d; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("upstream handler was not invoked")
|
||||
}
|
||||
|
||||
// Injected header contains the real token (not the attacker value).
|
||||
if got := captured.Get(tc.wantInjectedHeader); got != tc.wantInjectedValue {
|
||||
t.Errorf("%s = %q, want %q", tc.wantInjectedHeader, got, tc.wantInjectedValue)
|
||||
}
|
||||
|
||||
// All other auth headers must be stripped.
|
||||
for _, h := range tc.wantStrippedHeaders {
|
||||
if got := captured.Get(h); got != "" {
|
||||
t.Errorf("%s should be stripped, got %q", h, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-auth headers still forwarded.
|
||||
if got := captured.Get("X-Custom-Header"); got != "keep-me" {
|
||||
t.Errorf("X-Custom-Header = %q, want %q", got, "keep-me")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAllowedHosts(t *testing.T) {
|
||||
feishu := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
|
||||
}
|
||||
lark := struct{ Open, Accounts, MCP string }{
|
||||
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
|
||||
}
|
||||
hosts := buildAllowedHosts(feishu, lark)
|
||||
// feishu hosts
|
||||
if !hosts["open.feishu.cn"] {
|
||||
t.Error("expected open.feishu.cn in allowlist")
|
||||
}
|
||||
if !hosts["mcp.feishu.cn"] {
|
||||
t.Error("expected mcp.feishu.cn in allowlist")
|
||||
}
|
||||
// lark hosts
|
||||
if !hosts["open.larksuite.com"] {
|
||||
t.Error("expected open.larksuite.com in allowlist")
|
||||
}
|
||||
if !hosts["mcp.larksuite.com"] {
|
||||
t.Error("expected mcp.larksuite.com in allowlist")
|
||||
}
|
||||
// evil host
|
||||
if hosts["evil.com"] {
|
||||
t.Error("evil.com should not be in allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"/open-apis/im/v1/messages?receive_id_type=chat_id", "/open-apis/im/v1/messages"},
|
||||
{"/open-apis/calendar/v4/events", "/open-apis/calendar/v4/events"},
|
||||
{"/open-apis/docx/v1/documents/doxcnABCD1234/blocks", "/open-apis/docx/v1/documents/:id/blocks"},
|
||||
{"/open-apis/im/v1/chats/oc_abcdef12345678/members", "/open-apis/im/v1/chats/:id/members"},
|
||||
{"/path?secret=abc", "/path"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := sanitizePath(tt.input); got != tt.want {
|
||||
t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeID(t *testing.T) {
|
||||
tests := []struct {
|
||||
seg string
|
||||
want bool
|
||||
}{
|
||||
{"doxcnABCD1234", true}, // doc token
|
||||
{"oc_abcdef12345678", true}, // chat ID
|
||||
{"v1", false}, // API version
|
||||
{"messages", false}, // route keyword
|
||||
{"open-apis", false}, // route prefix
|
||||
{"ab1", false}, // too short
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := looksLikeID(tt.seg); got != tt.want {
|
||||
t.Errorf("looksLikeID(%q) = %v, want %v", tt.seg, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeError(t *testing.T) {
|
||||
short := fmt.Errorf("short error")
|
||||
if got := sanitizeError(short); got != "short error" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
|
||||
longMsg := make([]byte, 300)
|
||||
for i := range longMsg {
|
||||
longMsg[i] = 'x'
|
||||
}
|
||||
long := fmt.Errorf("%s", string(longMsg))
|
||||
got := sanitizeError(long)
|
||||
if len(got) > 210 {
|
||||
t.Errorf("expected truncation, got %d chars", len(got))
|
||||
}
|
||||
if !bytes.HasSuffix([]byte(got), []byte("...")) {
|
||||
t.Errorf("expected '...' suffix, got %q", got[len(got)-10:])
|
||||
}
|
||||
}
|
||||
167
sidecar/server-demo/main.go
Normal file
167
sidecar/server-demo/main.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_demo
|
||||
|
||||
// Command sidecar-server-demo is a reference implementation of a sidecar
|
||||
// auth proxy server. It is NOT production-ready — integrators should
|
||||
// implement their own server conforming to the wire protocol defined in
|
||||
// github.com/larksuite/cli/sidecar.
|
||||
//
|
||||
// The demo reuses the lark-cli credential pipeline (keychain + config) to
|
||||
// resolve real tokens, so it only works on a machine that has been
|
||||
// configured with `lark-cli auth login`.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", sidecar.DefaultListenAddr, "listen address (host:port)")
|
||||
keyFile := flag.String("key-file", defaultKeyFile(), "path to write the HMAC key")
|
||||
logFile := flag.String("log-file", "", "audit log file (stderr if empty)")
|
||||
profile := flag.String("profile", "", "lark-cli profile name (empty = active profile)")
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := run(ctx, *listen, *keyFile, *logFile, *profile); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultKeyFile() string {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, ".lark-sidecar", "proxy.key")
|
||||
}
|
||||
return "/tmp/lark-sidecar/proxy.key"
|
||||
}
|
||||
|
||||
func run(ctx context.Context, listen, keyFile, logFile, profile string) error {
|
||||
// Reject self-proxy: if this process inherited AUTH_PROXY, the sidecar
|
||||
// credential provider would activate and return sentinel tokens instead
|
||||
// of real ones, breaking the "trusted side holds real credentials" premise.
|
||||
if v := os.Getenv(envvars.CliAuthProxy); v != "" {
|
||||
return fmt.Errorf("%s is set in this environment (%s); unset it before starting the sidecar server", envvars.CliAuthProxy, v)
|
||||
}
|
||||
if listen == "" {
|
||||
return fmt.Errorf("invalid --listen address: empty")
|
||||
}
|
||||
|
||||
// Generate HMAC key (32 bytes = 256 bits) and write it to disk (0600).
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return fmt.Errorf("failed to generate HMAC key: %v", err)
|
||||
}
|
||||
keyHex := hex.EncodeToString(keyBytes)
|
||||
|
||||
keyDir := filepath.Dir(keyFile)
|
||||
if err := vfs.MkdirAll(keyDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create key directory: %v", err)
|
||||
}
|
||||
if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write key file: %v", err)
|
||||
}
|
||||
|
||||
// Audit logger: file or stderr.
|
||||
var auditLogger *log.Logger
|
||||
if logFile != "" {
|
||||
f, err := vfs.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
auditLogger = log.New(f, "", log.LstdFlags)
|
||||
} else {
|
||||
auditLogger = log.New(os.Stderr, "[audit] ", log.LstdFlags)
|
||||
}
|
||||
|
||||
// Reuse the lark-cli credential pipeline. A production implementation
|
||||
// would likely source credentials from a secrets manager instead.
|
||||
factory := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
|
||||
cfg, err := factory.Config()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %v", listen, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
allowedHosts := buildAllowedHosts(
|
||||
core.ResolveEndpoints(core.BrandFeishu),
|
||||
core.ResolveEndpoints(core.BrandLark),
|
||||
)
|
||||
allowedIDs := buildAllowedIdentities(cfg)
|
||||
|
||||
handler := &proxyHandler{
|
||||
key: []byte(keyHex),
|
||||
cred: factory.Credential,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
logger: auditLogger,
|
||||
forwardCl: newForwardClient(),
|
||||
allowedHosts: allowedHosts,
|
||||
allowedIDs: allowedIDs,
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
auditLogger.Println("shutting down...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
auditLogger.Printf("shutdown error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
keyPrefix := keyHex
|
||||
if len(keyPrefix) > 8 {
|
||||
keyPrefix = keyPrefix[:8]
|
||||
}
|
||||
proxyURL := "http://" + listen
|
||||
fmt.Fprintf(os.Stderr, "Auth sidecar listening on %s\n", proxyURL)
|
||||
fmt.Fprintf(os.Stderr, "HMAC key prefix: %s\n", keyPrefix)
|
||||
fmt.Fprintf(os.Stderr, "Full key written to %s (mode 0600)\n", keyFile)
|
||||
fmt.Fprintf(os.Stderr, "\nSet in sandbox:\n")
|
||||
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAuthProxy, proxyURL)
|
||||
fmt.Fprintf(os.Stderr, " export %s=\"<read from %s>\"\n", envvars.CliProxyKey, keyFile)
|
||||
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAppID, cfg.AppID)
|
||||
fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliBrand, string(cfg.Brand))
|
||||
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("sidecar server exited unexpectedly: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -26,9 +26,11 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
- `get` — 获取单个审批实例详情
|
||||
- `cancel` — 撤回审批实例
|
||||
- `cc` — 抄送审批实例
|
||||
- `initiated` — 查询用户的已发起列表
|
||||
|
||||
### tasks
|
||||
|
||||
- `remind` — 催办审批人
|
||||
- `approve` — 同意审批任务
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
@@ -41,6 +43,8 @@ lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
| `instances.get` | `approval:instance:read` |
|
||||
| `instances.cancel` | `approval:instance:write` |
|
||||
| `instances.cc` | `approval:instance:write` |
|
||||
| `instances.initiated` | `approval:instance:read` |
|
||||
| `tasks.remind` | `approval:instance:write` |
|
||||
| `tasks.approve` | `approval:task:write` |
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|----------------------------|
|
||||
| `allow_edit` | bool | 可新增、删除、修改视图,未提及默认为 `false` |
|
||||
| `allow_edit` | bool | 可新增、删除、修改视图;表权限为 `edit` 时默认为 `true`,表权限为 `read_only` 或用户明确限制时为 `false` |
|
||||
| `visibility` | object | 可见的视图配置 |
|
||||
| `visibility.all_visible` | bool | 是否全部可见 |
|
||||
| `visibility.visible_views` | []string | 可见视图名称 列表 |
|
||||
@@ -203,7 +203,7 @@
|
||||
输出 `view_rule` 时,**必须**使用以下完整结构,根据场景选择对应模板:
|
||||
|
||||
```json
|
||||
// 情况 A:用户要求可编辑/新增/删除视图 → allow_edit 设为 true
|
||||
// 情况 A:表权限为 edit 且用户未明确限制 → allow_edit 默认为 true,全部可见
|
||||
{
|
||||
"view_rule": {
|
||||
"allow_edit": true,
|
||||
@@ -213,7 +213,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 B:用户未提及具体视图,未要求编辑视图 → 全部可见、不可编辑
|
||||
// 情况 B:表权限为 read_only,或用户明确说不可编辑视图 → 全部可见、不可编辑
|
||||
{
|
||||
"view_rule": {
|
||||
"allow_edit": false,
|
||||
@@ -223,10 +223,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 情况 C:用户提及了具体视图 → 仅指定视图可见
|
||||
// 情况 C:用户提及了具体视图 → 仅指定视图可见(allow_edit 仍按 A/B 规则判断)
|
||||
{
|
||||
"view_rule": {
|
||||
"allow_edit": false,
|
||||
"allow_edit": true,
|
||||
"visibility": {
|
||||
"all_visible": false,
|
||||
"visible_views": ["表格视图", "看板视图"]
|
||||
@@ -415,7 +415,15 @@
|
||||
| 仪表盘访问 | 不配置 | 用户明确提及该仪表盘 |
|
||||
| `base_rule_map.copy` | `false` | 用户明确要求"允许复制" |
|
||||
| `base_rule_map.download` | `false` | 用户明确要求"允许下载/打印/副本" |
|
||||
| `record_operations` 中的 `delete` | 不包含 | 用户明确说"允许删除"或使用强语义("完全管理""可删改") |
|
||||
|
||||
### 默认开启项(条件性)
|
||||
|
||||
以下能力在特定条件下**默认开启**,用户明确限制时才排除:
|
||||
|
||||
| 能力 | 默认值 | 排除条件 |
|
||||
|------|--------|----------|
|
||||
| `record_operations` 中的 `delete` | **包含**(`perm = edit` 时) | 用户明确限制时才排除 |
|
||||
| `view_rule.allow_edit` | **`true`**(`perm = edit` 时) | 用户明确限制"不可编辑视图"或 `perm = read_only` 时设为 `false` |
|
||||
|
||||
---
|
||||
|
||||
@@ -436,7 +444,7 @@
|
||||
### 记录操作默认策略
|
||||
|
||||
**注意**:
|
||||
- 用户未提及时,默认包含 `add`,默认不包含 `delete`
|
||||
- 用户未提及时,表权限为 `edit` 时默认同时包含 `add` 和 `delete`,默认不包含 `delete` 的情况仅适用于用户明确限制操作的场景
|
||||
- 阅读范围默认对齐编辑范围:用户仅描述可编辑范围、未说明阅读范围时,可阅读范围与可编辑范围保持一致,不主动扩大
|
||||
- 当可读范围与可编辑范围一致时,**不得**生成 `read_filter_rule_group`;应设置 `other_record_all_read = false` 且 `read_filter_rule_group = null`
|
||||
|
||||
@@ -475,7 +483,7 @@
|
||||
1. **先判断用户是否提及了具体视图名称**(如"看板视图可见""甘特图不可编辑"等)
|
||||
- **是** → `all_visible = false`,`visible_views` 仅包含用户明确提及为"可见"的视图名称(非 viewID);未提及的视图视为不可见
|
||||
- **否**(用户完全未提及任何视图)→ `all_visible = true`
|
||||
2. `allow_edit` 默认为 `false`,仅当用户明确要求"可编辑视图""可新增/删除视图""可管理视图"时才设为 `true`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A)
|
||||
2. `allow_edit` 在表权限为 `edit` 时**默认为 `true`**;仅当用户明确限制"不可编辑视图"时才设为 `false`。设为 `true` 时仍**必须**包含 `visibility` 字段(参考视图权限 情况 A)
|
||||
3. `all_visible` 为 `false` 时,`visible_views` **不可为空**,必须至少包含一个视图
|
||||
|
||||
**❌ 常见错误 — 缺少 `visibility` 字段:**
|
||||
|
||||
@@ -221,6 +221,19 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
|
||||
| [`+get-dropdown`](references/lark-sheets-get-dropdown.md) | 查询下拉列表配置 |
|
||||
| [`+delete-dropdown`](references/lark-sheets-delete-dropdown.md) | 删除下拉列表 |
|
||||
|
||||
### 浮动图片
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+media-upload`](references/lark-sheets-media-upload.md) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) |
|
||||
| [`+create-float-image`](references/lark-sheets-create-float-image.md) | 创建浮动图片 |
|
||||
| [`+update-float-image`](references/lark-sheets-update-float-image.md) | 更新浮动图片属性 |
|
||||
| [`+get-float-image`](references/lark-sheets-get-float-image.md) | 获取浮动图片 |
|
||||
| [`+list-float-images`](references/lark-sheets-list-float-images.md) | 查询所有浮动图片 |
|
||||
| [`+delete-float-image`](references/lark-sheets-delete-float-image.md) | 删除浮动图片 |
|
||||
|
||||
> 浮动图片相关的读接口只返回元数据(含 `float_image_token`),**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png`。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
@@ -247,6 +260,14 @@ lark-cli sheets <resource> <method> [flags] # 调用 API
|
||||
|
||||
- `find` — 查找单元格
|
||||
|
||||
### spreadsheet.sheet.float_images
|
||||
|
||||
- `create` — 创建浮动图片
|
||||
- `patch` — 更新浮动图片
|
||||
- `get` — 获取浮动图片
|
||||
- `query` — 查询所有浮动图片
|
||||
- `delete` — 删除浮动图片
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
@@ -259,4 +280,9 @@ lark-cli sheets <resource> <method> [flags] # 调用 API
|
||||
| `spreadsheet.sheet.filters.get` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.filters.update` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheets.find` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.float_images.create` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheet.float_images.patch` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheet.float_images.get` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.float_images.query` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.float_images.delete` | `sheets:spreadsheet:write_only` |
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
# sheets +create-float-image(创建浮动图片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +create-float-image`。
|
||||
|
||||
在工作表中创建浮动图片。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 前置步骤:获取 float_image_token
|
||||
|
||||
`--float-image-token` 由 [`sheets +media-upload`](lark-sheets-media-upload.md) 产出(内部走 `drive/v1/medias/upload_all`,`>20MB` 自动切到分片上传,详见 [`lark-sheets-media-upload.md`](lark-sheets-media-upload.md)):
|
||||
|
||||
```bash
|
||||
# 1. 上传图片,自动计算大小、自动分片
|
||||
lark-cli sheets +media-upload --url "<url>" --file ./image.png
|
||||
# 响应: {"file_token":"boxcnXXXX","file_name":"image.png","size":123456,"spreadsheet_token":"<token>"}
|
||||
|
||||
# 2. 用返回的 file_token 作为 --float-image-token
|
||||
lark-cli sheets +create-float-image --url "<url>" --sheet-id "<sheetId>" \
|
||||
--float-image-token "boxcnXXXX" --range "<sheetId>!A1:A1"
|
||||
```
|
||||
|
||||
> **常见错误**:
|
||||
> - 用 `drive +upload` 的 token → 报 `Wrong Float Image Token`(走的是不同的上传接口,token 格式不兼容;必须用 `sheets +media-upload`)
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +create-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-token "boxcnXXXX" \
|
||||
--range "<sheetId>!A1:A1" --width 200 --height 150
|
||||
|
||||
# 指定自定义 ID 和偏移
|
||||
lark-cli sheets +create-float-image --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-token "boxcnXXXX" \
|
||||
--range "<sheetId>!B2:B2" --width 300 --height 200 \
|
||||
--offset-x 10 --offset-y 20 --float-image-id "myImg12345"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--float-image-token` | 是 | 图片 token(通过上方「前置步骤」的素材上传接口获取,不能用 `drive +upload` 的 token) |
|
||||
| `--range` | 是 | 锚定单元格,必须是单格(如 `sheetId!A1:A1`)。CLI 会校验前缀必须等于 `--sheet-id` |
|
||||
| `--width` | 否 | 图片宽度(像素,`>=20`;不传则使用图片原始宽度) |
|
||||
| `--height` | 否 | 图片高度(像素,`>=20`;不传则使用图片原始高度) |
|
||||
| `--offset-x` | 否 | 图片**左上角**到**锚定单元格左上角**的横向距离(向右为正,像素);`>=0` 且**小于锚定单元格的宽度**(超限由服务端拒绝) |
|
||||
| `--offset-y` | 否 | 图片**左上角**到**锚定单元格左上角**的纵向距离(向下为正,像素);`>=0` 且**小于锚定单元格的高度**(超限由服务端拒绝) |
|
||||
| `--float-image-id` | 否 | 自定义 10 位字母数字 ID(不传则自动生成) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `float_image`(float_image_id, float_image_token, range, width, height, offset_x, offset_y)。**只返回元数据,不含图片字节**,如需查看图片内容见下方「读取图片内容」。
|
||||
|
||||
## 读取图片内容
|
||||
|
||||
本接口及 `+get-float-image` / `+list-float-images` 均只返回 `float_image_token`。要读取图片字节,用该 token 调 `docs +media-preview`:
|
||||
|
||||
```bash
|
||||
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
|
||||
```
|
||||
|
||||
`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- `1310246 Wrong Float Image Value`:width/height/offset 参数不合法,CLI 会自动在 hint 中指向 `--width / --height / --offset-x / --offset-y`。典型成因:
|
||||
- `--width` / `--height` 小于 20;
|
||||
- `--offset-x` 大于等于锚定单元格宽度(或 `--offset-y` 大于等于单元格高度);
|
||||
- 传了负值。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-update-float-image](lark-sheets-update-float-image.md)
|
||||
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md)
|
||||
- [lark-sheets-list-float-images](lark-sheets-list-float-images.md)
|
||||
- [lark-sheets-delete-float-image](lark-sheets-delete-float-image.md)
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
# sheets +delete-float-image(删除浮动图片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +delete-float-image`。
|
||||
|
||||
删除工作表中的浮动图片。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**删除操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-id "fi12345678"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--float-image-id` | 是 | 浮动图片 ID |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `code`(0=成功)和 `msg`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)
|
||||
- [lark-sheets-list-float-images](lark-sheets-list-float-images.md)
|
||||
44
skills/lark-sheets/references/lark-sheets-get-float-image.md
Normal file
44
skills/lark-sheets/references/lark-sheets-get-float-image.md
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
# sheets +get-float-image(获取浮动图片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +get-float-image`。
|
||||
|
||||
获取单个浮动图片的详细信息。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-id "fi12345678"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--float-image-id` | 是 | 浮动图片 ID |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `float_image`(float_image_id, float_image_token, range, width, height, offset_x, offset_y)。**只返回元数据,不含图片字节**。
|
||||
|
||||
## 读取图片内容
|
||||
|
||||
本接口只返回 `float_image_token`。要读取图片字节,用 token 调 `docs +media-preview`:
|
||||
|
||||
```bash
|
||||
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
|
||||
```
|
||||
|
||||
`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-list-float-images](lark-sheets-list-float-images.md)
|
||||
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
# sheets +list-float-images(查询浮动图片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +list-float-images`。
|
||||
|
||||
查询工作表中的所有浮动图片。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含 `items` 数组,每项为一个 float_image 对象(含 `float_image_token`)。**只返回元数据,不含图片字节**。
|
||||
|
||||
## 读取图片内容
|
||||
|
||||
本接口只返回 `float_image_token`。要读取图片字节,用 token 调 `docs +media-preview`:
|
||||
|
||||
```bash
|
||||
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
|
||||
```
|
||||
|
||||
`user` / `bot` 身份都可用,前提是调用方对该 spreadsheet 具备读权限。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md)
|
||||
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)
|
||||
74
skills/lark-sheets/references/lark-sheets-media-upload.md
Normal file
74
skills/lark-sheets/references/lark-sheets-media-upload.md
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
# sheets +media-upload(上传浮动图片素材)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +media-upload`。
|
||||
|
||||
把本地图片上传到指定电子表格的素材空间,返回 `file_token`,该 token 可以作为 [`+create-float-image`](lark-sheets-create-float-image.md) 的 `--float-image-token` 使用。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作**(创建素材)—— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 说明
|
||||
|
||||
- 内部调用 `drive/v1/medias/upload_all`,`parent_type` 锁定为 `sheet_image`,`parent_node` 取自 `--url` / `--spreadsheet-token`。
|
||||
- 文件大小通过 `FileIO.Stat` 自动读取,无需手动算(跨平台一致)。
|
||||
- `>20MB` 自动切换到分片上传(`upload_prepare` → `upload_part` → `upload_finish`),无需额外参数。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 小文件(<=20MB)
|
||||
lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--file ./image.png
|
||||
|
||||
# 也支持 --spreadsheet-token
|
||||
lark-cli sheets +media-upload --spreadsheet-token "shtxxxxxxxx" --file ./image.png
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--file` | 是 | 本地图片路径,**必须是相对当前工作目录的相对路径**(见下方「注意事项」);>20MB 自动分片 |
|
||||
| `--dry-run` | 否 | 仅打印请求计划,不执行 |
|
||||
|
||||
## 输出
|
||||
|
||||
```json
|
||||
{
|
||||
"file_token": "boxcnXXXX",
|
||||
"file_name": "image.png",
|
||||
"size": 358934,
|
||||
"spreadsheet_token": "shtxxxxxxxx"
|
||||
}
|
||||
```
|
||||
|
||||
## 典型用法:上传 + 插入
|
||||
|
||||
```bash
|
||||
# 1. 上传
|
||||
TOKEN=$(lark-cli sheets +media-upload --url "<url>" --file ./image.png --jq '.data.file_token')
|
||||
|
||||
# 2. 插入浮动图片
|
||||
lark-cli sheets +create-float-image --url "<url>" --sheet-id "<sheetId>" \
|
||||
--float-image-token "$TOKEN" --range "<sheetId>!A1:A1" --width 300 --height 200
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **`--file` 只接受当前工作目录(CWD)下的相对路径**。CLI 的 `SafeInputPath` 会拒绝绝对路径以及逃出 CWD 的路径(`..` 展开后超出 CWD 也会拒)。
|
||||
- ❌ 错误:`--file /Users/alice/Desktop/image.png`
|
||||
- ❌ 错误:`--file ~/Desktop/image.png`(shell 会展开为绝对路径)
|
||||
- ✅ 正确:`cp /Users/alice/Desktop/image.png ./image.png && lark-cli sheets +media-upload --file ./image.png ...`
|
||||
- 典型报错:`unsafe file path: --file must be a relative path within the current directory`。
|
||||
- 所需权限:`docs:document.media:upload`(与 docs/slides/base 的媒体上传共用同一 scope)。
|
||||
- 返回的 `file_token` **只能**用于浮动图片;走 `drive +upload` 拿到的 token 格式不兼容,会报 `Wrong Float Image Token`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md) — 用返回的 token 创建浮动图片
|
||||
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md) — 读取浮动图片元数据
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
# sheets +update-float-image(更新浮动图片)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli sheets +update-float-image`。
|
||||
|
||||
更新浮动图片的位置、大小和偏移量。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-id "fi12345678" \
|
||||
--width 400 --height 300 --offset-y 20
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--float-image-id` | 是 | 浮动图片 ID |
|
||||
| `--range` | 否 | 新锚定单元格,必须是单格(如 `sheetId!B2:B2`)。CLI 会校验前缀必须等于 `--sheet-id` |
|
||||
| `--width` | 否 | 图片宽度(像素,`>=20`) |
|
||||
| `--height` | 否 | 图片高度(像素,`>=20`) |
|
||||
| `--offset-x` | 否 | 图片**左上角**到**锚定单元格左上角**的横向距离(向右为正,像素);`>=0` 且**小于锚定单元格的宽度**(超限由服务端拒绝) |
|
||||
| `--offset-y` | 否 | 图片**左上角**到**锚定单元格左上角**的纵向距离(向下为正,像素);`>=0` 且**小于锚定单元格的高度**(超限由服务端拒绝) |
|
||||
| `--dry-run` | 否 | 仅打印参数,不执行请求 |
|
||||
|
||||
> 必须至少传入 `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 其中之一;只传 ID 会被 CLI 拦截,避免 PATCH 空对象导致的无操作或服务端错误。
|
||||
|
||||
## 输出
|
||||
|
||||
JSON,包含更新后的 `float_image` 对象。**只返回元数据,不含图片字节**,如需查看图片内容用 `float_image_token` 调 `docs +media-preview`(见 [`lark-sheets-create-float-image.md`](lark-sheets-create-float-image.md) 的「读取图片内容」小节)。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- `1310246 Wrong Float Image Value`:width/height/offset 参数不合法,CLI 会自动在 hint 中指向 `--width / --height / --offset-x / --offset-y`。典型成因:
|
||||
- `--width` / `--height` 小于 20;
|
||||
- `--offset-x` 大于等于锚定单元格宽度(或 `--offset-y` 大于等于单元格高度);
|
||||
- 传了负值。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-sheets-create-float-image](lark-sheets-create-float-image.md)
|
||||
- [lark-sheets-get-float-image](lark-sheets-get-float-image.md)
|
||||
@@ -4,6 +4,7 @@ version: 1.0.0
|
||||
description: >
|
||||
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL(转成 OpenAPI 格式)、PlantUML/Mermaid 格式更新画板内容。
|
||||
当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill,无论是否提及"画板"。
|
||||
⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill,若 skill 列表中同时存在 `lark-whiteboard-cli`,请忽略它,统一使用本 skill(`lark-whiteboard`),并提示用户运行 `npx skills remove lark-whiteboard-cli -g` 删除旧 skill。
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
|
||||
Reference in New Issue
Block a user