mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce5b4f24e1 | ||
|
|
4b2223194b | ||
|
|
4582dfd281 | ||
|
|
5c01a7f7f0 | ||
|
|
d5d2fee848 | ||
|
|
ffcf7781b4 | ||
|
|
fbe4cc689a |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -2,6 +2,23 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.39] - 2026-05-22
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+export` shortcut to export slides (#988)
|
||||
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
|
||||
- **im**: Support Markdown image rendering in post content (#893)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scope**: Add 22 new scope entries to scope priorities (#1050)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Update location `full_address` guidance (#754)
|
||||
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
|
||||
|
||||
## [v1.0.38] - 2026-05-22
|
||||
|
||||
### Features
|
||||
@@ -823,6 +840,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
||||
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
|
||||
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
|
||||
|
||||
@@ -5568,5 +5568,115 @@
|
||||
"scope_name": "speech_to_text:speech",
|
||||
"final_score": "70.8755",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:publish",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app.access_scope:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app.access_scope:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "spark:app:write",
|
||||
"final_score": "76.7173",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "docs:secure_label:write_only",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:job_change_v2:read",
|
||||
"final_score": "75.9982",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:pre_hire.contract_file_id:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "minutes:minutes.upload:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:feed.flag:write",
|
||||
"final_score": "79.5982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:feed.flag:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "search:bot",
|
||||
"final_score": "67.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "application:bot.basic_info:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "drive:quota_detail:read_one",
|
||||
"final_score": "75.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "docs:permission.member:apply",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "corehr:employment.custom_field:write",
|
||||
"final_score": "75.6587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:message.group_at_msg.include_bot:readonly",
|
||||
"final_score": "88.9982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "okr:okr.setting:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.leader_id:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.dotted_line_leaders:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "directory:employee.base.active_status:read",
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.38",
|
||||
"version": "1.0.39",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
var DriveExport = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+export",
|
||||
Description: "Export a doc/docx/sheet/bitable to a local file with limited polling",
|
||||
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
|
||||
Risk: "read",
|
||||
Scopes: []string{
|
||||
"docs:document.content:read",
|
||||
@@ -32,8 +32,8 @@ var DriveExport = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "source document token", Required: true},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
|
||||
@@ -131,15 +131,15 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable":
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType)
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base":
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base", spec.FileExtension)
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension)
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
@@ -150,6 +150,14 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return output.ErrValidation("--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
|
||||
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
|
||||
@@ -345,6 +353,8 @@ func exportFileSuffix(fileExtension string) string {
|
||||
return ".csv"
|
||||
case "base":
|
||||
return ".base"
|
||||
case "pptx":
|
||||
return ".pptx"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -70,4 +70,10 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
|
||||
if got := exportFileSuffix("base"); got != ".base" {
|
||||
t.Fatalf("exportFileSuffix(base) = %q, want %q", got, ".base")
|
||||
}
|
||||
if got := ensureExportFileExtension("report", "pptx"); got != "report.pptx" {
|
||||
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "report.pptx")
|
||||
}
|
||||
if got := ensureExportFileExtension("report.pptx", "pptx"); got != "report.pptx" {
|
||||
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,34 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "base bitable ok",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
|
||||
},
|
||||
{
|
||||
name: "slides pptx ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
|
||||
},
|
||||
{
|
||||
name: "slides pdf ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pdf"},
|
||||
},
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
|
||||
wantErr: "only supports --doc-type bitable",
|
||||
},
|
||||
{
|
||||
name: "pptx non slides rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
|
||||
wantErr: "only supports --doc-type slides",
|
||||
},
|
||||
{
|
||||
name: "slides csv rejected",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
|
||||
wantErr: "slides only supports",
|
||||
},
|
||||
{
|
||||
name: "unknown doc type rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "unknown", FileExtension: "pdf"},
|
||||
wantErr: "invalid --doc-type",
|
||||
},
|
||||
{
|
||||
name: "unknown file extension rejected",
|
||||
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "rtf"},
|
||||
|
||||
@@ -911,12 +911,16 @@ func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
return marshalJSONNoEscape(payload)
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildSingleMDPost(markdown string) string {
|
||||
return marshalMarkdownPostContent([][]map[string]interface{}{
|
||||
buildPostElementNodes(optimizeMarkdownStyle(markdown)),
|
||||
{{
|
||||
"tag": "md",
|
||||
"text": optimizeMarkdownStyle(markdown),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -940,7 +944,10 @@ func buildSegmentedPost(markdown string) string {
|
||||
if optimized == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, buildPostElementNodes(optimized))
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": optimized,
|
||||
}})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return buildSingleMDPost(markdown)
|
||||
@@ -955,186 +962,8 @@ func buildMarkdownPostContent(markdown string) string {
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
|
||||
// buildPostElementNodes splits optimized markdown text into Feishu post inline
|
||||
// elements. It tokenizes markdown links/images and bare http(s) URLs:
|
||||
// - markdown links are kept verbatim inside a {"tag":"md"} segment
|
||||
// - bare URLs become {"tag":"a"} elements rendered natively by Feishu,
|
||||
// avoiding the md renderer misinterpreting underscores as italic markers
|
||||
//
|
||||
// Fenced code blocks are protected before tokenization so their content remains
|
||||
// a single md segment, and bare URLs support balanced parentheses in the path.
|
||||
func buildPostElementNodes(text string) []map[string]interface{} {
|
||||
protected, codeBlocks := protectMarkdownCodeBlocks(text)
|
||||
if protected == "" {
|
||||
return []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
}}
|
||||
}
|
||||
elems := make([]map[string]interface{}, 0, 4)
|
||||
prev := 0
|
||||
for i := 0; i < len(protected); {
|
||||
end, kind, ok := scanPostToken(protected, i)
|
||||
if !ok {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i > prev {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:i], codeBlocks))
|
||||
}
|
||||
|
||||
token := protected[i:end]
|
||||
if kind == postTokenMarkdown {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token, codeBlocks))
|
||||
} else {
|
||||
url := trimBareURLToken(token)
|
||||
if url == "" {
|
||||
url = token
|
||||
}
|
||||
elems = append(elems, map[string]interface{}{
|
||||
"tag": "a",
|
||||
"text": url,
|
||||
"href": url,
|
||||
})
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token[len(url):], codeBlocks))
|
||||
}
|
||||
prev = end
|
||||
i = end
|
||||
}
|
||||
if prev < len(protected) {
|
||||
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:], codeBlocks))
|
||||
}
|
||||
if len(elems) == 0 {
|
||||
return []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
}}
|
||||
}
|
||||
return elems
|
||||
}
|
||||
|
||||
func trimBareURLToken(token string) string {
|
||||
trimmed := strings.TrimRight(token, ".,;:!?")
|
||||
for strings.HasSuffix(trimmed, ")") && strings.Count(trimmed, "(") < strings.Count(trimmed, ")") {
|
||||
trimmed = strings.TrimSuffix(trimmed, ")")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
type postTokenKind int
|
||||
|
||||
const (
|
||||
postTokenMarkdown postTokenKind = iota
|
||||
postTokenURL
|
||||
)
|
||||
|
||||
func appendMDPostNode(elems []map[string]interface{}, text string) []map[string]interface{} {
|
||||
if text == "" {
|
||||
return elems
|
||||
}
|
||||
return append(elems, map[string]interface{}{
|
||||
"tag": "md",
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
|
||||
func scanPostToken(text string, start int) (end int, kind postTokenKind, ok bool) {
|
||||
if end, ok = scanMarkdownLinkToken(text, start); ok {
|
||||
return end, postTokenMarkdown, true
|
||||
}
|
||||
if end, ok = scanBareURLToken(text, start); ok {
|
||||
return end, postTokenURL, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func scanMarkdownLinkToken(text string, start int) (int, bool) {
|
||||
openBracket := start
|
||||
if text[start] == '!' {
|
||||
if start+1 >= len(text) || text[start+1] != '[' {
|
||||
return 0, false
|
||||
}
|
||||
openBracket = start + 1
|
||||
} else if text[start] != '[' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
closeBracket := strings.IndexByte(text[openBracket+1:], ']')
|
||||
if closeBracket < 0 {
|
||||
return 0, false
|
||||
}
|
||||
closeBracket += openBracket + 1
|
||||
if closeBracket+1 >= len(text) || text[closeBracket+1] != '(' {
|
||||
return 0, false
|
||||
}
|
||||
return scanBalancedParenToken(text, closeBracket+1)
|
||||
}
|
||||
|
||||
func scanBareURLToken(text string, start int) (int, bool) {
|
||||
if !strings.HasPrefix(text[start:], "http://") && !strings.HasPrefix(text[start:], "https://") {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
for i := start; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case ' ', '\t', '\n', '\r', '<', '>', '"', '[', ']':
|
||||
return i, i > start
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
if depth == 0 {
|
||||
return i, i > start
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return len(text), true
|
||||
}
|
||||
|
||||
func scanBalancedParenToken(text string, openParen int) (int, bool) {
|
||||
if openParen >= len(text) || text[openParen] != '(' {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
for i := openParen; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func buildPostElements(text string) string {
|
||||
return marshalJSONNoEscape(buildPostElementNodes(text))
|
||||
}
|
||||
|
||||
func marshalJSONNoEscape(v interface{}) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
return strings.TrimSuffix(buf.String(), "\n")
|
||||
}
|
||||
|
||||
// marshalStringNoEscape serializes a string to JSON without HTML-escaping
|
||||
// special characters like &, <, >. Go's json.Marshal escapes them to \u0026
|
||||
// etc. by default, which breaks URLs containing & in Feishu's md renderer.
|
||||
func marshalStringNoEscape(s string) string {
|
||||
return marshalJSONNoEscape(s)
|
||||
}
|
||||
|
||||
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
||||
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
|
||||
// Bare URLs are emitted as {"tag":"a"} elements to avoid Feishu's md renderer
|
||||
// misinterpreting underscores in URLs as italic markers.
|
||||
func wrapMarkdownAsPost(markdown string) string {
|
||||
return buildMarkdownPostContent(markdown)
|
||||
}
|
||||
|
||||
@@ -373,171 +373,19 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalStringNoEscape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "ampersand not escaped", input: "a=1&b=2", want: `"a=1&b=2"`},
|
||||
{name: "angle brackets not escaped", input: "<tag>", want: `"<tag>"`},
|
||||
{name: "regular string", input: "hello world", want: `"hello world"`},
|
||||
{name: "url with ampersand", input: "https://example.com?a=1&b=2", want: `"https://example.com?a=1&b=2"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := marshalStringNoEscape(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("marshalStringNoEscape(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostElements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantSubs []string // substrings that must appear
|
||||
wantNsubs []string // substrings that must NOT appear
|
||||
}{
|
||||
{
|
||||
name: "plain text no URL",
|
||||
input: "hello **world**",
|
||||
wantSubs: []string{`"tag":"md"`, `hello **world**`},
|
||||
},
|
||||
{
|
||||
name: "bare URL only",
|
||||
input: "https://example.com/path",
|
||||
wantSubs: []string{`"tag":"a"`, `"text":"https://example.com/path"`, `"href":"https://example.com/path"`},
|
||||
},
|
||||
{
|
||||
name: "bare URL with underscores",
|
||||
input: "https://example.com/flow_id=abc_def",
|
||||
wantSubs: []string{`"tag":"a"`, `flow_id=abc_def`},
|
||||
},
|
||||
{
|
||||
name: "bare URL with ampersand not escaped",
|
||||
input: "https://example.com?a=1&b=2",
|
||||
wantSubs: []string{`"tag":"a"`, `a=1&b=2`},
|
||||
},
|
||||
{
|
||||
name: "text before and after URL",
|
||||
input: "click here: https://example.com/path ok?",
|
||||
wantSubs: []string{`"tag":"md"`, `click here: `, `"tag":"a"`, `https://example.com/path`, ` ok?`},
|
||||
},
|
||||
{
|
||||
name: "markdown link kept in md segment",
|
||||
input: "[click here](https://example.com/path_with_underscore)",
|
||||
wantSubs: []string{`"tag":"md"`, `[click here](https://example.com/path_with_underscore)`},
|
||||
},
|
||||
{
|
||||
name: "markdown link not promoted to a tag",
|
||||
input: "[text](https://example.com)",
|
||||
wantSubs: []string{`"tag":"md"`},
|
||||
wantNsubs: []string{`"tag":"a"`},
|
||||
},
|
||||
{
|
||||
name: "multiple bare URLs",
|
||||
input: "https://a.com/x_y and https://b.com/p_q",
|
||||
wantSubs: []string{
|
||||
`"tag":"a"`, `https://a.com/x_y`,
|
||||
`https://b.com/p_q`,
|
||||
`"tag":"md"`, ` and `,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed markdown and bare URL",
|
||||
input: "**bold** https://example.com/foo_bar [link](https://example.com) end",
|
||||
wantSubs: []string{`"tag":"md"`, `**bold**`, `"tag":"a"`, `foo_bar`, `[link](https://example.com)`},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantSubs: []string{`"tag":"md"`, `"text":""`},
|
||||
},
|
||||
{
|
||||
name: "URL followed by comma",
|
||||
input: "visit https://example.com/path, then click",
|
||||
wantSubs: []string{`"tag":"a"`, `"href":"https://example.com/path"`},
|
||||
wantNsubs: []string{`https://example.com/path,`},
|
||||
},
|
||||
{
|
||||
name: "URL followed by period",
|
||||
input: "see https://example.com/foo.",
|
||||
wantSubs: []string{`"tag":"a"`, `https://example.com/foo`},
|
||||
wantNsubs: []string{`https://example.com/foo."`},
|
||||
},
|
||||
{
|
||||
name: "URL with no trailing punctuation unchanged",
|
||||
input: "https://example.com/foo_bar",
|
||||
wantSubs: []string{`"href":"https://example.com/foo_bar"`},
|
||||
},
|
||||
{
|
||||
name: "URL with balanced parentheses preserved",
|
||||
input: "https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
wantSubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_(bar)"`},
|
||||
wantNsubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_"`},
|
||||
},
|
||||
{
|
||||
name: "code block URL stays markdown",
|
||||
input: "```bash\ncurl https://example.com/foo_bar\n```",
|
||||
wantSubs: []string{`"tag":"md"`, "```bash\\ncurl https://example.com/foo_bar\\n```"},
|
||||
wantNsubs: []string{`"tag":"a"`},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildPostElements(tt.input)
|
||||
for _, sub := range tt.wantSubs {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("buildPostElements(%q)\n got: %s\n missing: %q", tt.input, got, sub)
|
||||
}
|
||||
}
|
||||
for _, sub := range tt.wantNsubs {
|
||||
if strings.Contains(got, sub) {
|
||||
t.Errorf("buildPostElements(%q)\n got: %s\n should not contain: %q", tt.input, got, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost(t *testing.T) {
|
||||
t.Run("plain markdown", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare URL becomes a tag", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("see https://example.com/flow_id=abc_def done")
|
||||
if !strings.Contains(got, `"tag":"a"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() bare URL should produce a tag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `flow_id=abc_def`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() URL content missing: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("code block URL stays md", func(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("```bash\ncurl https://example.com/foo_bar\n```")
|
||||
if strings.Contains(got, `"tag":"a"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() code block URL should stay markdown: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "```bash\\ncurl https://example.com/foo_bar\\n```") {
|
||||
t.Fatalf("wrapMarkdownAsPost() code block content missing: %s", got)
|
||||
}
|
||||
})
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
}
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSegmentedPost(t *testing.T) {
|
||||
|
||||
@@ -195,3 +195,9 @@ its identity flipped (bot↔user) or its auth-header redirected (e.g. into
|
||||
| `allowlist.go` | Target host / identity allowlists |
|
||||
| `audit.go` | Log path/error sanitization |
|
||||
| `handler_test.go` | Unit tests for all of the above |
|
||||
|
||||
## See also
|
||||
|
||||
- [server-multi-tenant-demo](../server-multi-tenant-demo/) — extends this demo
|
||||
with per-client HMAC key isolation, OAuth device-flow login, and persistent
|
||||
client → user mapping for multi-tenant deployments
|
||||
|
||||
281
sidecar/server-multi-tenant-demo/README.md
Normal file
281
sidecar/server-multi-tenant-demo/README.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Multi-Tenant Sidecar Server Demo
|
||||
|
||||
> ⚠️ **This is a demo.** For production deployment, implement your own sidecar
|
||||
> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`.
|
||||
|
||||
## Problem
|
||||
|
||||
Organizations often manage **multiple Lark/Feishu apps** (e.g. one per
|
||||
department, one per product line), each with its own `app_id` and `app_secret`.
|
||||
These credentials must never be exposed to end-user environments (CI runners,
|
||||
developer sandboxes, containerized workspaces). At the same time, when multiple
|
||||
users share the same sidecar infrastructure, their Feishu identities must be
|
||||
strictly isolated — user A must never accidentally operate as user B.
|
||||
|
||||
The single-tenant [server-demo](../server-demo/) solves the credential-hiding
|
||||
problem for **one app with one user**. This multi-tenant demo extends it to
|
||||
support:
|
||||
|
||||
1. **Multiple apps** — run one sidecar instance per app; each instance holds
|
||||
its own `app_id` / `app_secret` and listens on a separate port. Clients
|
||||
choose which app to use by pointing `LARKSUITE_CLI_AUTH_PROXY` to the
|
||||
corresponding port.
|
||||
2. **Per-client identity isolation** — each client environment gets a unique
|
||||
HMAC key. The sidecar identifies request origin by matching the HMAC
|
||||
signature and injects the correct user's token. No fallback to other
|
||||
users' tokens.
|
||||
3. **Self-service user login** — management endpoints let each client initiate
|
||||
an OAuth device-flow login to bind their own Feishu identity, without
|
||||
exposing `app_secret` to the client.
|
||||
|
||||
## Typical deployment
|
||||
|
||||
```text
|
||||
Trusted Host
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ sidecar instance A (port 16384) │
|
||||
│ app_id=cli_aaa app_secret=*** │
|
||||
│ keys/proxy.key keys/alice.key keys/bob… │
|
||||
│ │
|
||||
│ sidecar instance B (port 16385) │
|
||||
│ app_id=cli_bbb app_secret=*** │
|
||||
│ keys/proxy.key keys/charlie.key ... │
|
||||
└─────────────┬────────────────────────────────┘
|
||||
│ same machine (loopback / docker bridge)
|
||||
┌─────────────┴────────────────────────────────┐
|
||||
│ Client sandbox (container / CI runner) │
|
||||
│ │
|
||||
│ LARKSUITE_CLI_AUTH_PROXY=http://host:16384 │
|
||||
│ LARKSUITE_CLI_PROXY_KEY=<contents of │
|
||||
│ alice.key> │
|
||||
│ LARKSUITE_CLI_APP_ID=cli_aaa │
|
||||
│ LARKSUITE_CLI_BRAND=feishu │
|
||||
│ │
|
||||
│ $ lark api GET /open-apis/... --as user │
|
||||
│ → sidecar matches alice.key │
|
||||
│ → injects alice's Feishu user token │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- `app_id` and `app_secret` live only on the trusted host — clients only
|
||||
know `app_id` (needed for the CLI's credential pipeline) and their own
|
||||
HMAC key.
|
||||
- Each sidecar instance binds one app. Multiple apps = multiple instances
|
||||
on different ports.
|
||||
- Clients select which app to use by choosing which sidecar port to connect
|
||||
to (via `LARKSUITE_CLI_AUTH_PROXY`).
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Sidecar Server │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Shared Key │ │ Per-Client Keys │ │
|
||||
│ │ (proxy.key) │ │ alice.key, bob.key, ... │ │
|
||||
│ └──────┬──────┘ └──────────────┬───────────────┘ │
|
||||
│ │ management plane │ data plane │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Auth Bridge │ │ Proxy Handler │ │
|
||||
│ │ login/poll/ │ │ HMAC verify → identify │ │
|
||||
│ │ status │ │ client → inject user token │ │
|
||||
│ └─────────────┘ └──────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Dual-key design:**
|
||||
- **Management plane** (login flow): all clients use the shared `proxy.key`.
|
||||
This allows any client to initiate login and query status without needing
|
||||
individual key files pre-provisioned.
|
||||
- **Data plane** (API proxy): each client uses its own `{name}.key` for HMAC
|
||||
signing. The sidecar identifies the client by matching which key verifies
|
||||
the request signature, then injects that client's bound user token.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build -tags authsidecar_multi_tenant_demo \
|
||||
-o sidecar-multi-tenant-demo \
|
||||
./sidecar/server-multi-tenant-demo/
|
||||
```
|
||||
|
||||
## Server setup
|
||||
|
||||
### 1. Configure the Lark app (trusted side only)
|
||||
|
||||
```bash
|
||||
lark-cli config init --new # set app_id / app_secret
|
||||
```
|
||||
|
||||
### 2. Prepare the keys directory
|
||||
|
||||
```text
|
||||
keys/
|
||||
├── proxy.key # shared key (auto-generated on first run)
|
||||
├── alice.key # client "alice" — generate with: openssl rand -hex 32 > alice.key
|
||||
├── bob.key # client "bob"
|
||||
└── charlie.key # client "charlie"
|
||||
```
|
||||
|
||||
- Each file contains a 64-character hex string (32 bytes).
|
||||
- Filename stem (without `.key`) becomes the client identity.
|
||||
- `proxy.key` is excluded from client key scanning.
|
||||
- Keys are auto-rescanned on cache miss — add a new `.key` file and the next
|
||||
unrecognized request will trigger a rescan; no restart needed.
|
||||
- Duplicate key values and shared-key collisions are rejected with a warning.
|
||||
|
||||
### 3. Start the server
|
||||
|
||||
```bash
|
||||
./sidecar-multi-tenant-demo \
|
||||
--listen 127.0.0.1:16384 \
|
||||
--key-file /path/to/keys/proxy.key \
|
||||
--keys-dir /path/to/keys/ \
|
||||
--log-file /path/to/audit.log
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
|
||||
| `--key-file` | `~/.lark-sidecar/proxy.key` | Shared HMAC key path (created if absent) |
|
||||
| `--keys-dir` | *(parent of `--key-file`)* | Directory containing per-client `*.key` files |
|
||||
| `--log-file` | *(stderr)* | Audit log output path |
|
||||
| `--profile` | *(active profile)* | lark-cli profile name for credential lookup |
|
||||
|
||||
## Client setup
|
||||
|
||||
**No changes to `lark-cli` itself are required.** The standard sidecar env
|
||||
vars are all that's needed — the multi-tenant isolation is entirely
|
||||
server-side.
|
||||
|
||||
### Required environment variables
|
||||
|
||||
```bash
|
||||
# Point to the sidecar instance for the desired app
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
|
||||
|
||||
# Client-specific HMAC key (data-plane identity)
|
||||
export LARKSUITE_CLI_PROXY_KEY="$(cat /path/to/keys/alice.key)"
|
||||
|
||||
# Must match the app configured on the sidecar instance
|
||||
export LARKSUITE_CLI_APP_ID="cli_xxx"
|
||||
|
||||
# feishu or lark
|
||||
export LARKSUITE_CLI_BRAND="feishu"
|
||||
```
|
||||
|
||||
### Multi-app switching (multiple sidecar instances)
|
||||
|
||||
When the server operator runs multiple sidecar instances (one per app), clients
|
||||
switch between apps by changing `LARKSUITE_CLI_AUTH_PROXY` to point to the
|
||||
appropriate port:
|
||||
|
||||
```bash
|
||||
# App A (e.g. "Marketing" app)
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
|
||||
export LARKSUITE_CLI_APP_ID="cli_marketing_app"
|
||||
|
||||
# App B (e.g. "Engineering" app)
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16385"
|
||||
export LARKSUITE_CLI_APP_ID="cli_engineering_app"
|
||||
```
|
||||
|
||||
A client-side helper script can present these as a menu (e.g. "Select
|
||||
company"), reading from a local config file that maps app names to ports.
|
||||
The sidecar itself does not implement app selection — it is one instance per
|
||||
app by design.
|
||||
|
||||
### User login flow
|
||||
|
||||
Once the env vars are set, the client authenticates via the management
|
||||
endpoints. A helper script (or manual `curl`) calls:
|
||||
|
||||
1. **Login**: `POST /_sidecar/auth/login` with `{"client_id": "alice"}` →
|
||||
returns a device code and verification URL.
|
||||
2. **User opens the URL in a browser** and authorizes the app.
|
||||
3. **Poll**: `POST /_sidecar/auth/poll` with `{"device_code": "...", "client_id": "alice"}` →
|
||||
blocks until authorization completes.
|
||||
4. **Status**: `POST /_sidecar/auth/status` with `{"client_id": "alice"}` →
|
||||
returns the bound user name and token status.
|
||||
|
||||
All management requests are signed with the **shared `proxy.key`** (not the
|
||||
client-specific key). The `client_id` in the body tells the sidecar which
|
||||
client→user mapping to update.
|
||||
|
||||
After login, `lark-cli` commands (`lark api ...`, `lark doc ...`, etc.) work
|
||||
immediately — the sidecar injects the correct user token based on the
|
||||
client's HMAC key, with no additional configuration needed.
|
||||
|
||||
### Example: end-to-end workflow
|
||||
|
||||
```bash
|
||||
# 1. Server operator generates a key for a new client
|
||||
openssl rand -hex 32 > /path/to/keys/alice.key
|
||||
|
||||
# 2. Client environment is configured (e.g. in .bashrc or container init)
|
||||
export LARKSUITE_CLI_AUTH_PROXY="http://host.docker.internal:16384"
|
||||
export LARKSUITE_CLI_PROXY_KEY="$(cat /path/to/keys/alice.key)"
|
||||
export LARKSUITE_CLI_APP_ID="cli_xxx"
|
||||
export LARKSUITE_CLI_BRAND="feishu"
|
||||
|
||||
# 3. Client logs in (one-time)
|
||||
# (using a helper script that calls the management endpoints)
|
||||
lark-auth login
|
||||
|
||||
# 4. Client uses lark-cli as normal — identity is automatically resolved
|
||||
lark api GET /open-apis/authen/v1/user_info --as user
|
||||
# → returns alice's Feishu identity, not another user's
|
||||
```
|
||||
|
||||
## Management endpoints
|
||||
|
||||
| Endpoint | Method | Body | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `/_sidecar/auth/login` | POST | `{"client_id": "...", "domains": [...]}` | Start OAuth device-flow |
|
||||
| `/_sidecar/auth/poll` | POST | `{"device_code": "...", "client_id": "..."}` | Poll for completion |
|
||||
| `/_sidecar/auth/status` | POST | `{"client_id": "..."}` | Query status and mapping |
|
||||
|
||||
All management requests require HMAC signing with the shared `proxy.key`.
|
||||
The HMAC covers method, path, timestamp, and body SHA-256 — see
|
||||
`verifyManagementHMAC` in `auth_bridge.go` for the canonical string format.
|
||||
|
||||
## Design decisions
|
||||
|
||||
1. **HMAC key as client identity** — the key is the existing trust anchor.
|
||||
Using it for identification introduces no new trust assumptions and
|
||||
prevents a malicious client from spoofing another client's identity
|
||||
(unlike a header-based approach).
|
||||
|
||||
2. **No fallback on unmapped clients** — this is authentication. Silently
|
||||
falling back to another user's token is a security violation. Unmapped
|
||||
clients receive an explicit error prompting them to log in.
|
||||
|
||||
3. **One sidecar instance per app** — keeps `app_secret` scoping simple and
|
||||
avoids cross-app token confusion. Multi-app support is achieved by running
|
||||
multiple instances on different ports.
|
||||
|
||||
4. **Proxy.key reuse across restarts** — when multiple sidecar instances start
|
||||
concurrently, they all write to the same key file. The last writer wins,
|
||||
leaving other instances with stale in-memory keys. Reusing the existing
|
||||
key eliminates this race.
|
||||
|
||||
## Source layout
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `main.go` | Entry point: flag parsing, key loading, server lifecycle |
|
||||
| `handler.go` | `proxyHandler.ServeHTTP` — multi-key HMAC verification and request forwarding |
|
||||
| `auth_bridge.go` | Management endpoints: login, poll, status, user mapping persistence |
|
||||
| `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 |
|
||||
|
||||
## See also
|
||||
|
||||
- [server-demo](../server-demo/) — single-tenant minimal implementation
|
||||
- [`sidecar` package](https://pkg.go.dev/github.com/larksuite/cli/sidecar) — wire protocol
|
||||
44
sidecar/server-multi-tenant-demo/allowlist.go
Normal file
44
sidecar/server-multi-tenant-demo/allowlist.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_multi_tenant_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-multi-tenant-demo/audit.go
Normal file
51
sidecar/server-multi-tenant-demo/audit.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_multi_tenant_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
|
||||
}
|
||||
530
sidecar/server-multi-tenant-demo/auth_bridge.go
Normal file
530
sidecar/server-multi-tenant-demo/auth_bridge.go
Normal file
@@ -0,0 +1,530 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_multi_tenant_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// authBridge handles /_sidecar/auth/* management endpoints.
|
||||
// Supports multi-user token isolation: each client environment gets its own
|
||||
// Feishu identity via a clientName → feishuOpenId mapping.
|
||||
//
|
||||
// Identity chain: PROXY_KEY → clientName → feishuOpenId → keychain token
|
||||
type authBridge struct {
|
||||
key []byte
|
||||
appID string
|
||||
appSecret string
|
||||
brand core.LarkBrand
|
||||
cred *credential.CredentialProvider
|
||||
logger *log.Logger
|
||||
httpCl *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
pendingPolls map[string]context.CancelFunc
|
||||
|
||||
// clientName → feishuOpenId (protected by mu)
|
||||
userMap map[string]string
|
||||
mapFile string
|
||||
}
|
||||
|
||||
func newAuthBridge(key []byte, appID, appSecret string, brand core.LarkBrand, cred *credential.CredentialProvider, logger *log.Logger) *authBridge {
|
||||
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
|
||||
mapFile := ""
|
||||
if configDir != "" {
|
||||
mapFile = filepath.Join(configDir, "client_user_map.json")
|
||||
}
|
||||
|
||||
ab := &authBridge{
|
||||
key: key,
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
brand: brand,
|
||||
cred: cred,
|
||||
logger: logger,
|
||||
httpCl: &http.Client{Timeout: 30 * time.Second},
|
||||
pendingPolls: make(map[string]context.CancelFunc),
|
||||
userMap: make(map[string]string),
|
||||
mapFile: mapFile,
|
||||
}
|
||||
ab.loadUserMap()
|
||||
return ab
|
||||
}
|
||||
|
||||
func (ab *authBridge) loadUserMap() {
|
||||
if ab.mapFile == "" {
|
||||
return
|
||||
}
|
||||
data, err := vfs.ReadFile(ab.mapFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]string
|
||||
if json.Unmarshal(data, &m) == nil && m != nil {
|
||||
ab.userMap = m
|
||||
}
|
||||
}
|
||||
|
||||
func (ab *authBridge) saveUserMap() {
|
||||
if ab.mapFile == "" {
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(ab.userMap, "", " ")
|
||||
if err != nil {
|
||||
ab.logger.Printf("AUTH_BRIDGE_ERROR action=save_user_map error=%q", err.Error())
|
||||
return
|
||||
}
|
||||
if err := vfs.WriteFile(ab.mapFile, data, 0600); err != nil {
|
||||
ab.logger.Printf("AUTH_BRIDGE_ERROR action=save_user_map error=%q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// verifyManagementHMAC checks a simplified HMAC for management endpoints.
|
||||
// Canonical string: "sidecar-mgmt\n<method>\n<path>\n<timestamp>\n<body_sha256>"
|
||||
func (ab *authBridge) verifyManagementHMAC(r *http.Request, body []byte) error {
|
||||
ts := r.Header.Get("X-Sidecar-Timestamp")
|
||||
sig := r.Header.Get("X-Sidecar-Signature")
|
||||
bodySha := r.Header.Get("X-Sidecar-Body-SHA256")
|
||||
|
||||
if ts == "" || sig == "" || bodySha == "" {
|
||||
return fmt.Errorf("missing required headers")
|
||||
}
|
||||
|
||||
tsVal, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp")
|
||||
}
|
||||
drift := math.Abs(float64(time.Now().Unix() - tsVal))
|
||||
if drift > 60 {
|
||||
return fmt.Errorf("timestamp drift %.0fs exceeds limit", drift)
|
||||
}
|
||||
|
||||
actualSha := sha256Hex(body)
|
||||
if bodySha != actualSha {
|
||||
return fmt.Errorf("body SHA256 mismatch")
|
||||
}
|
||||
|
||||
canonical := "sidecar-mgmt\n" + r.Method + "\n" + r.URL.Path + "\n" + ts + "\n" + bodySha
|
||||
mac := hmac.New(sha256.New, ab.key)
|
||||
mac.Write([]byte(canonical))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if !hmac.Equal([]byte(expected), []byte(sig)) {
|
||||
return fmt.Errorf("HMAC signature mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// ServeHTTP routes management API requests.
|
||||
func (ab *authBridge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "failed to read body")
|
||||
return
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
if err := ab.verifyManagementHMAC(r, body); err != nil {
|
||||
jsonError(w, http.StatusUnauthorized, "HMAC verification failed: "+err.Error())
|
||||
ab.logger.Printf("AUTH_BRIDGE_REJECT path=%s reason=%q", r.URL.Path, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/_sidecar/auth/login":
|
||||
ab.handleLogin(w, r, body)
|
||||
case "/_sidecar/auth/poll":
|
||||
ab.handlePoll(w, r, body)
|
||||
case "/_sidecar/auth/status":
|
||||
ab.handleStatus(w, r, body)
|
||||
default:
|
||||
jsonError(w, http.StatusNotFound, "unknown management endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// parseClientID extracts the client identifier from a JSON body.
|
||||
func parseClientID(body []byte) string {
|
||||
var raw struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
}
|
||||
return raw.ClientID
|
||||
}
|
||||
|
||||
// handleLogin initiates a device-flow OAuth login.
|
||||
func (ab *authBridge) handleLogin(w http.ResponseWriter, _ *http.Request, body []byte) {
|
||||
var req struct {
|
||||
Scope string `json:"scope"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &req)
|
||||
}
|
||||
clientID := parseClientID(body)
|
||||
|
||||
scope := req.Scope
|
||||
if scope == "" {
|
||||
scope = loadCachedScopes()
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "offline_access"
|
||||
}
|
||||
|
||||
ab.logger.Printf("AUTH_BRIDGE_LOGIN_SCOPE scope_count=%d domains=%v client=%s",
|
||||
len(strings.Fields(scope)), req.Domains, clientID)
|
||||
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(
|
||||
ab.httpCl, ab.appID, ab.appSecret, ab.brand, scope, io.Discard,
|
||||
)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadGateway, "device authorization failed: "+err.Error())
|
||||
ab.logger.Printf("AUTH_BRIDGE_ERROR action=login error=%q", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ab.logger.Printf("AUTH_BRIDGE_LOGIN device_code_prefix=%s expires_in=%d",
|
||||
truncate(authResp.DeviceCode, 12), authResp.ExpiresIn)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"ok": true,
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"interval": authResp.Interval,
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
// handlePoll polls the device-flow token endpoint.
|
||||
// Binds the resulting feishu identity to the client on success.
|
||||
func (ab *authBridge) handlePoll(w http.ResponseWriter, r *http.Request, body []byte) {
|
||||
var req struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil || req.DeviceCode == "" {
|
||||
jsonError(w, http.StatusBadRequest, "device_code is required")
|
||||
return
|
||||
}
|
||||
clientID := parseClientID(body)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ab.mu.Lock()
|
||||
if oldCancel, ok := ab.pendingPolls[req.DeviceCode]; ok {
|
||||
oldCancel()
|
||||
}
|
||||
ab.pendingPolls[req.DeviceCode] = cancel
|
||||
ab.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
ab.mu.Lock()
|
||||
delete(ab.pendingPolls, req.DeviceCode)
|
||||
ab.mu.Unlock()
|
||||
}()
|
||||
|
||||
result := larkauth.PollDeviceToken(
|
||||
ctx, ab.httpCl, ab.appID, ab.appSecret, ab.brand,
|
||||
req.DeviceCode, 5, 600, io.Discard,
|
||||
)
|
||||
|
||||
if !result.OK {
|
||||
resp := map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": result.Error,
|
||||
"msg": result.Message,
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
ab.logger.Printf("AUTH_BRIDGE_POLL_FAIL device_code_prefix=%s error=%q",
|
||||
truncate(req.DeviceCode, 12), result.Message)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Token == nil {
|
||||
jsonError(w, http.StatusInternalServerError, "token response was nil")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
AppId: ab.appID,
|
||||
AccessToken: result.Token.AccessToken,
|
||||
RefreshToken: result.Token.RefreshToken,
|
||||
ExpiresAt: now + int64(result.Token.ExpiresIn)*1000,
|
||||
RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000,
|
||||
Scope: result.Token.Scope,
|
||||
GrantedAt: now,
|
||||
}
|
||||
|
||||
ep := core.ResolveEndpoints(ab.brand)
|
||||
openID, userName, err := fetchUserInfoDirect(ab.httpCl, ep.Open, result.Token.AccessToken)
|
||||
if err != nil {
|
||||
ab.logger.Printf("AUTH_BRIDGE_WARN action=user_info error=%q", err.Error())
|
||||
jsonError(w, http.StatusBadGateway, "login succeeded but failed to get user info: "+err.Error())
|
||||
return
|
||||
}
|
||||
storedToken.UserOpenId = openID
|
||||
|
||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "failed to store token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := addUserToConfig(ab.appID, openID, userName); err != nil {
|
||||
ab.logger.Printf("AUTH_BRIDGE_WARN action=sync_config error=%q", err.Error())
|
||||
}
|
||||
|
||||
if clientID != "" {
|
||||
ab.mu.Lock()
|
||||
ab.userMap[clientID] = openID
|
||||
ab.saveUserMap()
|
||||
ab.mu.Unlock()
|
||||
ab.logger.Printf("AUTH_BRIDGE_MAP client=%s -> feishu=%s (%s)",
|
||||
clientID, openID, userName)
|
||||
}
|
||||
|
||||
ab.logger.Printf("AUTH_BRIDGE_LOGIN_OK user=%s open_id=%s scope_count=%d client=%s",
|
||||
userName, openID, len(strings.Fields(result.Token.Scope)), clientID)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"ok": true,
|
||||
"user_name": userName,
|
||||
"open_id": openID,
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
// handleStatus returns current auth status.
|
||||
// Accepts client_id in body for client-specific mapping.
|
||||
func (ab *authBridge) handleStatus(w http.ResponseWriter, _ *http.Request, body []byte) {
|
||||
clientID := parseClientID(body)
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "failed to load config: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var users []map[string]interface{}
|
||||
for _, app := range multi.Apps {
|
||||
if app.AppId != ab.appID {
|
||||
continue
|
||||
}
|
||||
for _, u := range app.Users {
|
||||
stored := larkauth.GetStoredToken(ab.appID, u.UserOpenId)
|
||||
status := "unknown"
|
||||
if stored != nil {
|
||||
status = larkauth.TokenStatus(stored)
|
||||
}
|
||||
users = append(users, map[string]interface{}{
|
||||
"user_name": u.UserName,
|
||||
"user_open_id": u.UserOpenId,
|
||||
"token_status": status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": users,
|
||||
}
|
||||
|
||||
if clientID != "" {
|
||||
ab.mu.Lock()
|
||||
mappedOpenID := ab.userMap[clientID]
|
||||
ab.mu.Unlock()
|
||||
|
||||
resp["client_id"] = clientID
|
||||
resp["mapped_open_id"] = mappedOpenID
|
||||
if mappedOpenID != "" {
|
||||
stored := larkauth.GetStoredToken(ab.appID, mappedOpenID)
|
||||
if stored != nil {
|
||||
resp["mapped_status"] = larkauth.TokenStatus(stored)
|
||||
for _, u := range users {
|
||||
if u["user_open_id"] == mappedOpenID {
|
||||
resp["mapped_user_name"] = u["user_name"]
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resp["mapped_status"] = "no_token"
|
||||
}
|
||||
} else {
|
||||
resp["mapped_status"] = "not_mapped"
|
||||
}
|
||||
}
|
||||
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
// resolveUserTokenByClient resolves a UAT for a specific client environment.
|
||||
// Returns an error if the client has no user mapping — the user must
|
||||
// run the login flow first. No fallback to other users' tokens.
|
||||
func (ab *authBridge) resolveUserTokenByClient(clientName string) (string, error) {
|
||||
ab.mu.Lock()
|
||||
openID := ab.userMap[clientName]
|
||||
ab.mu.Unlock()
|
||||
|
||||
if openID == "" {
|
||||
ab.logger.Printf("AUTH_BRIDGE_REJECT_NO_MAPPING client=%s", clientName)
|
||||
return "", fmt.Errorf("client %q has no user mapping; run the login flow to authorize", clientName)
|
||||
}
|
||||
|
||||
ab.logger.Printf("AUTH_BRIDGE_RESOLVE client=%s feishu=%s", clientName, openID)
|
||||
|
||||
opts := larkauth.UATCallOptions{
|
||||
UserOpenId: openID,
|
||||
AppId: ab.appID,
|
||||
AppSecret: ab.appSecret,
|
||||
Domain: ab.brand,
|
||||
}
|
||||
token, err := larkauth.GetValidAccessToken(ab.httpCl, opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve token for user %s: %v", openID, err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func addUserToConfig(appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range multi.Apps {
|
||||
if multi.Apps[i].AppId != appID {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for j := range multi.Apps[i].Users {
|
||||
if multi.Apps[i].Users[j].UserOpenId == openID {
|
||||
multi.Apps[i].Users[j].UserName = userName
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
multi.Apps[i].Users = append(multi.Apps[i].Users, core.AppUser{
|
||||
UserOpenId: openID,
|
||||
UserName: userName,
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
}
|
||||
return fmt.Errorf("app %s not found in config", appID)
|
||||
}
|
||||
|
||||
func fetchUserInfoDirect(client *http.Client, openBase, accessToken string) (openID, name string, err error) {
|
||||
u := openBase + "/open-apis/authen/v1/user_info"
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
OpenID string `json:"open_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", "", fmt.Errorf("parse user_info response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", "", fmt.Errorf("user_info API error: [%d] %s", result.Code, result.Msg)
|
||||
}
|
||||
return result.Data.OpenID, result.Data.Name, nil
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func jsonError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
func loadCachedScopes() string {
|
||||
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
|
||||
if configDir == "" {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Join(configDir, "cache", "auth_login_scopes")
|
||||
entries, err := vfs.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := vfs.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var doc struct {
|
||||
RequestedScope string `json:"requested_scope"`
|
||||
}
|
||||
if json.Unmarshal(data, &doc) == nil && doc.RequestedScope != "" {
|
||||
return doc.RequestedScope
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
51
sidecar/server-multi-tenant-demo/forward.go
Normal file
51
sidecar/server-multi-tenant-demo/forward.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_multi_tenant_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
|
||||
}
|
||||
372
sidecar/server-multi-tenant-demo/handler.go
Normal file
372
sidecar/server-multi-tenant-demo/handler.go
Normal file
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_multi_tenant_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"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
|
||||
authBridge *authBridge
|
||||
|
||||
// Per-client key isolation: keyHex → clientName.
|
||||
// Data-plane requests are signed with a client-specific key;
|
||||
// the matched key determines which client (and thus which user
|
||||
// token) to use. Protected by ckMu.
|
||||
ckMu sync.RWMutex
|
||||
clientKeys map[string]clientKeyEntry
|
||||
keysDir string // directory to scan for *.key files (excluding proxy.key)
|
||||
}
|
||||
|
||||
type clientKeyEntry struct {
|
||||
key []byte
|
||||
clientName string
|
||||
}
|
||||
|
||||
// loadClientKeys scans keysDir for *.key files (excluding the shared
|
||||
// proxy.key) and populates the clientKeys map. The filename stem (without
|
||||
// .key) becomes the client identity. No naming convention is enforced.
|
||||
// Safe to call multiple times (e.g. on cache miss).
|
||||
func (h *proxyHandler) loadClientKeys() {
|
||||
if h.keysDir == "" {
|
||||
return
|
||||
}
|
||||
entries, err := vfs.ReadDir(h.keysDir)
|
||||
if err != nil {
|
||||
h.logger.Printf("KEYS_SCAN_ERROR dir=%s error=%q", h.keysDir, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sharedKeyHex := string(h.key)
|
||||
|
||||
newKeys := make(map[string]clientKeyEntry)
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".key") {
|
||||
continue
|
||||
}
|
||||
clientName := strings.TrimSuffix(name, ".key")
|
||||
if clientName == "" || clientName == "proxy" {
|
||||
continue
|
||||
}
|
||||
data, err := vfs.ReadFile(filepath.Join(h.keysDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
keyHex := strings.TrimSpace(string(data))
|
||||
if len(keyHex) != 64 {
|
||||
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"key length %d, expected 64\"", name, len(keyHex))
|
||||
continue
|
||||
}
|
||||
if keyHex == sharedKeyHex {
|
||||
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"collides with shared proxy key\"", name)
|
||||
continue
|
||||
}
|
||||
if existing, ok := newKeys[keyHex]; ok {
|
||||
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"duplicate key, already loaded for client %s\"", name, existing.clientName)
|
||||
continue
|
||||
}
|
||||
newKeys[keyHex] = clientKeyEntry{key: []byte(keyHex), clientName: clientName}
|
||||
}
|
||||
|
||||
h.ckMu.Lock()
|
||||
h.clientKeys = newKeys
|
||||
h.ckMu.Unlock()
|
||||
|
||||
if len(newKeys) > 0 {
|
||||
names := make([]string, 0, len(newKeys))
|
||||
for _, e := range newKeys {
|
||||
names = append(names, e.clientName)
|
||||
}
|
||||
h.logger.Printf("KEYS_LOADED count=%d clients=%v", len(newKeys), names)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyWithClientKeys tries each client key to verify the HMAC.
|
||||
// Returns the client name on success, or empty string + error if none match.
|
||||
func (h *proxyHandler) verifyWithClientKeys(cr sidecar.CanonicalRequest, signature string) (string, error) {
|
||||
h.ckMu.RLock()
|
||||
keys := h.clientKeys
|
||||
h.ckMu.RUnlock()
|
||||
|
||||
for _, entry := range keys {
|
||||
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
|
||||
return entry.clientName, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss: rescan keys directory and retry once
|
||||
h.loadClientKeys()
|
||||
|
||||
h.ckMu.RLock()
|
||||
keys = h.clientKeys
|
||||
h.ckMu.RUnlock()
|
||||
|
||||
for _, entry := range keys {
|
||||
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
|
||||
return entry.clientName, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no client key matched")
|
||||
}
|
||||
|
||||
// allowedAuthHeaders lists the only header names the sidecar will inject real
|
||||
// tokens into.
|
||||
var allowedAuthHeaders = map[string]bool{
|
||||
"Authorization": true,
|
||||
sidecar.HeaderMCPUAT: true,
|
||||
sidecar.HeaderMCPTAT: true,
|
||||
}
|
||||
|
||||
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Route management endpoints to authBridge (different HMAC scheme)
|
||||
if len(r.URL.Path) > 10 && r.URL.Path[:10] == "/_sidecar/" {
|
||||
if h.authBridge != nil {
|
||||
h.authBridge.ServeHTTP(w, r)
|
||||
} else {
|
||||
http.Error(w, "auth bridge not configured", http.StatusNotImplemented)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// 0. Check protocol version
|
||||
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
|
||||
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 := 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)
|
||||
cr := sidecar.CanonicalRequest{
|
||||
Version: version,
|
||||
Method: r.Method,
|
||||
Host: targetHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: claimedSHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
}
|
||||
|
||||
// Try the primary (shared) key first, then per-client keys.
|
||||
// matchedClient is empty when using the shared key.
|
||||
var matchedClient string
|
||||
if err := sidecar.Verify(h.key, cr, signature); err != nil {
|
||||
client, clientErr := h.verifyWithClientKeys(cr, signature)
|
||||
if clientErr != 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), "no key matched")
|
||||
return
|
||||
}
|
||||
matchedClient = client
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
// UAT (user identity): per-client isolation via matched PROXY_KEY.
|
||||
// TAT (bot identity): shared credential provider (app-level).
|
||||
var resolvedToken string
|
||||
if identity == sidecar.IdentityUser && h.authBridge != nil {
|
||||
token, err := h.authBridge.resolveUserTokenByClient(matchedClient)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to resolve user token: "+err.Error(), http.StatusInternalServerError)
|
||||
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s client=%s error=%q",
|
||||
r.Method, sanitizePath(pathAndQuery), identity, matchedClient, sanitizeError(err))
|
||||
return
|
||||
}
|
||||
resolvedToken = token
|
||||
} else {
|
||||
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeTAT,
|
||||
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
|
||||
}
|
||||
resolvedToken = tokenResult.Token
|
||||
}
|
||||
|
||||
// 7. Build forwarding request
|
||||
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
|
||||
}
|
||||
|
||||
for k, vs := range r.Header {
|
||||
if isProxyHeader(k) {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
forwardReq.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
forwardReq.Header.Del("Authorization")
|
||||
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
|
||||
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
// 8. Inject real token
|
||||
if authHeader == "Authorization" {
|
||||
forwardReq.Header.Set("Authorization", "Bearer "+resolvedToken)
|
||||
} else {
|
||||
forwardReq.Header.Set(authHeader, resolvedToken)
|
||||
}
|
||||
|
||||
// 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
|
||||
clientTag := ""
|
||||
if matchedClient != "" {
|
||||
clientTag = " client=" + matchedClient
|
||||
}
|
||||
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s%s",
|
||||
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond), clientTag)
|
||||
}
|
||||
|
||||
// parseTarget validates X-Lark-Proxy-Target and returns the host portion.
|
||||
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
|
||||
}
|
||||
878
sidecar/server-multi-tenant-demo/handler_test.go
Normal file
878
sidecar/server-multi-tenant-demo/handler_test.go
Normal file
@@ -0,0 +1,878 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_multi_tenant_demo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"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. A local
|
||||
// TLS test server stands in for the upstream so the test is fully offline.
|
||||
func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
|
||||
key := []byte("test-key")
|
||||
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
|
||||
|
||||
for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} {
|
||||
t.Run(good, func(t *testing.T) {
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
h := &proxyHandler{
|
||||
key: key,
|
||||
cred: cred,
|
||||
appID: "cli_test",
|
||||
logger: discardLogger(),
|
||||
forwardCl: upstream.Client(),
|
||||
allowedHosts: map[string]bool{upstreamHost: true},
|
||||
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
|
||||
}
|
||||
|
||||
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, good)
|
||||
resign(t, key, req, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("authHeader=%q: expected 200, got %d body=%s", good, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RejectsSelfProxy(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
keyPath := filepath.Join(t.TempDir(), "proxy.key")
|
||||
|
||||
err := run(context.Background(), "127.0.0.1:0", keyPath, "", "", "")
|
||||
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 := core.Endpoints{
|
||||
Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn",
|
||||
}
|
||||
lark := core.Endpoints{
|
||||
Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "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:])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Multi-tenant tests ----------
|
||||
|
||||
func writeKeyFile(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClientKeys_SkipsSharedKeyCollision(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sharedKey := strings.Repeat("aa", 32) // 64 hex chars
|
||||
aliceKey := strings.Repeat("bb", 32)
|
||||
|
||||
writeKeyFile(t, dir, "proxy.key", sharedKey)
|
||||
writeKeyFile(t, dir, "alice.key", aliceKey)
|
||||
writeKeyFile(t, dir, "evil.key", sharedKey) // same as shared key
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
h := &proxyHandler{
|
||||
key: []byte(sharedKey),
|
||||
keysDir: dir,
|
||||
clientKeys: make(map[string]clientKeyEntry),
|
||||
logger: log.New(&logBuf, "", 0),
|
||||
}
|
||||
h.loadClientKeys()
|
||||
|
||||
h.ckMu.RLock()
|
||||
defer h.ckMu.RUnlock()
|
||||
|
||||
if len(h.clientKeys) != 1 {
|
||||
t.Fatalf("expected 1 client key (alice), got %d", len(h.clientKeys))
|
||||
}
|
||||
for _, entry := range h.clientKeys {
|
||||
if entry.clientName != "alice" {
|
||||
t.Errorf("expected client alice, got %s", entry.clientName)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logBuf.String(), "KEYS_SCAN_SKIP") || !strings.Contains(logBuf.String(), "collides with shared proxy key") {
|
||||
t.Errorf("expected KEYS_SCAN_SKIP log for shared key collision, got: %s", logBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClientKeys_SkipsDuplicateKeyHex(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sharedKey := strings.Repeat("aa", 32)
|
||||
dupeKey := strings.Repeat("cc", 32)
|
||||
|
||||
writeKeyFile(t, dir, "proxy.key", sharedKey)
|
||||
writeKeyFile(t, dir, "alice.key", dupeKey)
|
||||
writeKeyFile(t, dir, "bob.key", dupeKey) // duplicate of alice
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
h := &proxyHandler{
|
||||
key: []byte(sharedKey),
|
||||
keysDir: dir,
|
||||
clientKeys: make(map[string]clientKeyEntry),
|
||||
logger: log.New(&logBuf, "", 0),
|
||||
}
|
||||
h.loadClientKeys()
|
||||
|
||||
h.ckMu.RLock()
|
||||
defer h.ckMu.RUnlock()
|
||||
|
||||
if len(h.clientKeys) != 1 {
|
||||
t.Fatalf("expected 1 client key (first loaded), got %d", len(h.clientKeys))
|
||||
}
|
||||
if !strings.Contains(logBuf.String(), "KEYS_SCAN_SKIP") || !strings.Contains(logBuf.String(), "duplicate key") {
|
||||
t.Errorf("expected KEYS_SCAN_SKIP log for duplicate key, got: %s", logBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadClientKeys_SkipsProxyAndNonKeyFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sharedKey := strings.Repeat("aa", 32)
|
||||
|
||||
writeKeyFile(t, dir, "proxy.key", sharedKey)
|
||||
writeKeyFile(t, dir, "alice.key", strings.Repeat("bb", 32))
|
||||
writeKeyFile(t, dir, "notes.txt", "not a key")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "subdir.key"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
h := &proxyHandler{
|
||||
key: []byte(sharedKey),
|
||||
keysDir: dir,
|
||||
clientKeys: make(map[string]clientKeyEntry),
|
||||
logger: log.New(&logBuf, "", 0),
|
||||
}
|
||||
h.loadClientKeys()
|
||||
|
||||
h.ckMu.RLock()
|
||||
defer h.ckMu.RUnlock()
|
||||
|
||||
if len(h.clientKeys) != 1 {
|
||||
t.Fatalf("expected 1 client key (alice), got %d", len(h.clientKeys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWithClientKeys_MatchesCorrectClient(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sharedKey := strings.Repeat("aa", 32)
|
||||
aliceKey := strings.Repeat("bb", 32)
|
||||
bobKey := strings.Repeat("cc", 32)
|
||||
|
||||
writeKeyFile(t, dir, "proxy.key", sharedKey)
|
||||
writeKeyFile(t, dir, "alice.key", aliceKey)
|
||||
writeKeyFile(t, dir, "bob.key", bobKey)
|
||||
|
||||
h := &proxyHandler{
|
||||
key: []byte(sharedKey),
|
||||
keysDir: dir,
|
||||
clientKeys: make(map[string]clientKeyEntry),
|
||||
logger: discardLogger(),
|
||||
}
|
||||
h.loadClientKeys()
|
||||
|
||||
cr := sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: "GET",
|
||||
Host: "open.feishu.cn",
|
||||
PathAndQuery: "/test",
|
||||
BodySHA256: sidecar.BodySHA256(nil),
|
||||
Timestamp: sidecar.Timestamp(),
|
||||
Identity: sidecar.IdentityBot,
|
||||
AuthHeader: "Authorization",
|
||||
}
|
||||
|
||||
// Sign with alice's key
|
||||
aliceSig := sidecar.Sign([]byte(aliceKey), cr)
|
||||
client, err := h.verifyWithClientKeys(cr, aliceSig)
|
||||
if err != nil {
|
||||
t.Fatalf("expected alice key to verify, got error: %v", err)
|
||||
}
|
||||
if client != "alice" {
|
||||
t.Errorf("expected client=alice, got %q", client)
|
||||
}
|
||||
|
||||
// Sign with bob's key
|
||||
bobSig := sidecar.Sign([]byte(bobKey), cr)
|
||||
client, err = h.verifyWithClientKeys(cr, bobSig)
|
||||
if err != nil {
|
||||
t.Fatalf("expected bob key to verify, got error: %v", err)
|
||||
}
|
||||
if client != "bob" {
|
||||
t.Errorf("expected client=bob, got %q", client)
|
||||
}
|
||||
|
||||
// Sign with unknown key
|
||||
unknownKey := strings.Repeat("dd", 32)
|
||||
unknownSig := sidecar.Sign([]byte(unknownKey), cr)
|
||||
client, err = h.verifyWithClientKeys(cr, unknownSig)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for unknown key, got client=%q", client)
|
||||
}
|
||||
if client != "" {
|
||||
t.Errorf("expected empty client for unknown key, got %q", client)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMap_RoundTripPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mapFile := filepath.Join(dir, "client_user_map.json")
|
||||
|
||||
ab := &authBridge{
|
||||
userMap: make(map[string]string),
|
||||
mapFile: mapFile,
|
||||
logger: discardLogger(),
|
||||
}
|
||||
|
||||
// Initially empty
|
||||
ab.loadUserMap()
|
||||
if len(ab.userMap) != 0 {
|
||||
t.Fatalf("expected empty map, got %v", ab.userMap)
|
||||
}
|
||||
|
||||
// Populate and save
|
||||
ab.userMap["alice"] = "ou_alice_open_id_123"
|
||||
ab.userMap["bob"] = "ou_bob_open_id_456"
|
||||
ab.saveUserMap()
|
||||
|
||||
// Verify file contents
|
||||
data, err := os.ReadFile(mapFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read map file: %v", err)
|
||||
}
|
||||
var saved map[string]string
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatalf("failed to parse saved map: %v", err)
|
||||
}
|
||||
if saved["alice"] != "ou_alice_open_id_123" || saved["bob"] != "ou_bob_open_id_456" {
|
||||
t.Errorf("saved map mismatch: %v", saved)
|
||||
}
|
||||
|
||||
// Create new instance and load — simulates restart
|
||||
ab2 := &authBridge{
|
||||
userMap: make(map[string]string),
|
||||
mapFile: mapFile,
|
||||
logger: discardLogger(),
|
||||
}
|
||||
ab2.loadUserMap()
|
||||
|
||||
if ab2.userMap["alice"] != "ou_alice_open_id_123" {
|
||||
t.Errorf("after reload, alice=%q, want ou_alice_open_id_123", ab2.userMap["alice"])
|
||||
}
|
||||
if ab2.userMap["bob"] != "ou_bob_open_id_456" {
|
||||
t.Errorf("after reload, bob=%q, want ou_bob_open_id_456", ab2.userMap["bob"])
|
||||
}
|
||||
}
|
||||
195
sidecar/server-multi-tenant-demo/main.go
Normal file
195
sidecar/server-multi-tenant-demo/main.go
Normal file
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar_multi_tenant_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"
|
||||
"strings"
|
||||
"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/validate"
|
||||
"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")
|
||||
keysDir := flag.String("keys-dir", "", "directory containing per-client *.key files for identity isolation (defaults to key-file's parent dir)")
|
||||
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, *keysDir, *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, keysDir, logFile, profile string) error {
|
||||
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")
|
||||
}
|
||||
|
||||
if _, err := validate.SafeInputPath(keyFile); err != nil {
|
||||
return fmt.Errorf("invalid --key-file path: %w", err)
|
||||
}
|
||||
if logFile != "" {
|
||||
if _, err := validate.SafeInputPath(logFile); err != nil {
|
||||
return fmt.Errorf("invalid --log-file path: %w", err)
|
||||
}
|
||||
}
|
||||
if keysDir != "" {
|
||||
if _, err := validate.SafeInputPath(keysDir); err != nil {
|
||||
return fmt.Errorf("invalid --keys-dir path: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse existing key if present; generate a new one only on first run.
|
||||
keyDir := filepath.Dir(keyFile)
|
||||
if err := vfs.MkdirAll(keyDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create key directory: %v", err)
|
||||
}
|
||||
|
||||
var keyHex string
|
||||
if existing, err := vfs.ReadFile(keyFile); err == nil && len(strings.TrimSpace(string(existing))) == 64 {
|
||||
keyHex = strings.TrimSpace(string(existing))
|
||||
} else {
|
||||
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)
|
||||
if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write key file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default keysDir to the parent directory of keyFile
|
||||
if keysDir == "" {
|
||||
keysDir = keyDir
|
||||
}
|
||||
|
||||
// Audit logger
|
||||
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)
|
||||
}
|
||||
|
||||
factory := cmdutil.NewDefault(nil, 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)
|
||||
|
||||
ab := newAuthBridge([]byte(keyHex), cfg.AppID, cfg.AppSecret, cfg.Brand, factory.Credential, auditLogger)
|
||||
|
||||
handler := &proxyHandler{
|
||||
key: []byte(keyHex),
|
||||
cred: factory.Credential,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
logger: auditLogger,
|
||||
forwardCl: newForwardClient(),
|
||||
allowedHosts: allowedHosts,
|
||||
allowedIDs: allowedIDs,
|
||||
authBridge: ab,
|
||||
keysDir: keysDir,
|
||||
}
|
||||
handler.loadClientKeys()
|
||||
|
||||
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, "Client keys dir: %s\n", keysDir)
|
||||
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
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
- **Base token 口径统一**:无论 Shortcut 还是原生 API,都统一使用 `base_token`
|
||||
- **附件字段**:上传本地文件时只能走 `lark-cli base +record-upload-attachment`
|
||||
- **地理位置字段**:写入必须使用 `{lng,lat}`;读取、筛选和转文本等场景使用 `full_address` 字符串,筛选优先用包含匹配;只有公式能访问坐标
|
||||
- **能力边界**:当前 `base/v3` 原生 spec 以单表 / 单记录 / 视图筛选配置为主,批量写入和旧 `search` 场景优先走 unified Shortcut 组合能力
|
||||
- **视图重命名确认规则**:用户已经明确“把哪个视图改成什么名字”时,执行 `table.views patch` / 对应 shortcut 直接改名即可,不需要再补一句确认
|
||||
- **删除确认规则(记录 / 字段 / 表)**:执行 `table.records delete / table.fields delete / tables delete` 或对应 shortcut 时,如果用户已经明确要求删除且目标明确,可以直接执行;只有目标不明确时才先追问
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-apps
|
||||
description: "把本地 HTML 文件或目录部署到飞书妙搭(Miaoda),生成可分享访问的 Web 页面并返回 URL;管理应用的创建、更新、列表和访问范围。当用户要把 HTML、静态网站或 Web demo 发布成可分享链接,或提到妙搭 / Miaoda 时使用。不用于:上传普通文件到云空间(用 lark-drive)、编辑飞书云文档内容(用 lark-doc)、创建飞书原生幻灯片 / 演示文稿(用 lark-slides)。"
|
||||
description: "把本地 HTML 文件或目录部署到飞书妙搭(Miaoda),生成一个公网可访问的应用及其链接(URL)。当用户要创建 HTML 或要把 HTML、静态网站或 Web demo 发布成公网可访问的链接 / 可分享链接、设置应用共享范围,或提到妙搭 / Miaoda 时使用。凡产出可独立访问的 HTML 产物都属本 skill 的潜在归宿,是否真要部署由 skill 内部协议判断。不用于:上传普通文件到云空间(用 lark-drive)、编辑飞书云文档内容(用 lark-doc)、创建飞书原生幻灯片 / 演示文稿(用 lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -28,6 +28,7 @@ lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
|
||||
3. **更新应用元信息(`apps +update`)** → 必读 [`lark-apps-update.md`](references/lark-apps-update.md)(部分更新,未传字段不变)
|
||||
4. **发布 HTML / PPT / 静态网站(`apps +html-publish`)** → 必读 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(`--path` 文件 vs 目录、tar.gz 打包不做过滤)
|
||||
5. **设置可用范围(`apps +access-scope-set`)** → 必读 [`lark-apps-access-scope-set.md`](references/lark-apps-access-scope-set.md)(specific / public / tenant 三态互斥校验、targets JSON 结构)
|
||||
6. **查看当前可用范围(`apps +access-scope-get`)** → 必读 [`lark-apps-access-scope-get.md`](references/lark-apps-access-scope-get.md)(响应 scope 枚举 `All` / `Tenant` / `Range` 与 CLI 的 `public` / `tenant` / `specific` 映射;含 jq 复制 scope 配置示例)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误、互斥违反或文件被错误打包。**
|
||||
|
||||
@@ -41,6 +42,11 @@ lark-cli apps +access-scope-set --app-id app_xxx --scope tenant
|
||||
lark-cli auth login --domain apps
|
||||
```
|
||||
|
||||
## 写 HTML 前的硬约束(避免 publish 阶段被拒)
|
||||
|
||||
- **入口文件必须叫 `index.html`** — 妙搭以 `index.html` 作为应用入口;目录形态时根目录下要有 `index.html`,单文件形态时文件名就是 `index.html`。命名成 `app.html` / `demo.html` 等会被 `+html-publish` 直接拒绝
|
||||
- **`--path` 不能等于当前工作目录(`.` / cwd)** — 源码硬拒,避免误把 `.git` / `.env` / `node_modules` 一并打包并通过 share URL 公开。HTML 产物放进具体子目录(如 `./dist`、`./public`、`./<page-name>/`)或单文件路径
|
||||
|
||||
## 端到端流程(HTML / PPT / 静态网站发布)
|
||||
|
||||
**第一步:判断用户意图是「明示部署」还是「仅演示」**:
|
||||
@@ -89,4 +95,5 @@ Shortcut 是对常用操作的高级封装(`lark-cli apps +<verb> [flags]`)
|
||||
| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(name / description / icon-url) |
|
||||
| [`+update`](references/lark-apps-update.md) | 部分更新应用名 / 描述(只发传入字段) |
|
||||
| [`+access-scope-set`](references/lark-apps-access-scope-set.md) | 设置应用可用范围(specific / public / tenant,三态互斥校验) |
|
||||
| [`+access-scope-get`](references/lark-apps-access-scope-get.md) | 查看应用当前可用范围(响应 scope 枚举 `All` / `Tenant` / `Range`;可作"备份 / 复制 scope 配置"前置读) |
|
||||
| [`+html-publish`](references/lark-apps-html-publish.md) | **把本地 HTML 文件 / 目录 / PPT / 静态网站部署为可分享的妙搭应用,返回访问 URL**(用户明示部署 / 分享时直接调;仅说"可演示"时先问用户是否要部署再调) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.0
|
||||
version: 1.2.1
|
||||
description: "当需要用 lark-cli 操作飞书多维表格(Base)时调用:搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -216,6 +216,7 @@ metadata:
|
||||
|----------|------|-----------------------------------------------------------|------|
|
||||
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
|
||||
| 地理位置字段 | 存储坐标并由平台解析地址 | 可以 | 写入必须使用 `{lng,lat}`;读取、筛选和转文本等场景使用 `full_address` 字符串;只有公式能访问坐标 |
|
||||
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
|
||||
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
|
||||
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
|
||||
@@ -230,6 +231,7 @@ metadata:
|
||||
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
|
||||
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| 写入地理位置 | `+record-upsert` / `+record-batch-*` 传 `{lng,lat}` | 不要把纯地址文本当成 CellValue |
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
### 2.8 location
|
||||
|
||||
用对象 `{lng, lat}`,两者都是数字;`lng` 是经度,`lat` 是纬度。
|
||||
写入对象必须使用 `{lng, lat}`,两者都是数字;`lng` 是经度,`lat` 是纬度。不需要手动传 `full_address`,平台会根据坐标解析地址。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -117,6 +117,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
读取、筛选、转文本等场景使用 `full_address` 字符串;只有公式能访问坐标。如果用户只给地址文本,先获取或确认坐标后再写入;不要把仅有地址文本直接当作 location CellValue。
|
||||
|
||||
### 2.9 attachment(不作为普通 CellValue 写入)
|
||||
|
||||
- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id <record_id> --field-id <field_id> --file <path>`;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口写附件值。
|
||||
|
||||
@@ -277,6 +277,7 @@ POST /open-apis/base/v3/bases/:base_token/data/query
|
||||
| `isEmpty` / `isNotEmpty` | `[]` | 0 个 | `[]` |
|
||||
|
||||
> **不支持** `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`:地理位置无自然顺序。
|
||||
> location 按 `full_address` 字符串筛选,不支持经纬度空间筛选;查城市/片区时优先用 `contains`,避免用 `is` 匹配短地址词。
|
||||
|
||||
*`checkbox`*
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
|
||||
| 目标类型 | 允许的源类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `text` | `number`、`select`、`datetime`、`created_at`、`updated_at`、`location`、`auto_number`、`checkbox` | 保留字符串表示;丢失原类型语义和结构化能力 |
|
||||
| `text` | `number`、`select`、`datetime`、`created_at`、`updated_at`、`location`(只保留 `full_address`)、`auto_number`、`checkbox` | 保留字符串表示;丢失原类型语义和结构化能力 |
|
||||
| `number` | `text`、`number`、`datetime`、`created_at`、`updated_at`、`checkbox` | 保留可解析的数字值;无法解析的值会变空,原文本格式会丢失 |
|
||||
| `datetime` | `text`、`number`、`datetime`、`created_at`、`updated_at` | 保留可解析的时间字符串和时间戳;无法解析的值会变空,原文本格式会丢失 |
|
||||
| `select` | `text -> select`、`number -> select`、`single select -> multi select` | 只有完全匹配目标选项名的值会转成对应选项;没匹配上的值会被丢弃 |
|
||||
|
||||
@@ -464,6 +464,8 @@
|
||||
{ "type": "location", "name": "位置" }
|
||||
```
|
||||
|
||||
写入必须使用 `{lng,lat}`。location 读回会包含 `full_address`;筛选和 `location -> text` 类型转换按 `full_address` 字符串处理,只有公式能访问坐标。
|
||||
|
||||
```json
|
||||
{ "type": "checkbox", "name": "完成" }
|
||||
```
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
### `text` / `location`
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
@@ -38,6 +38,16 @@
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
@@ -256,30 +256,30 @@ lark-cli drive permission.members create \
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| Shortcut | 说明 |
|
||||
|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
|
||||
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
|
||||
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/file/sheet/slides, also supports wiki URL resolving to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+version-history`](references/lark-drive-version-history.md) | List historical versions of a file with only_tag=true and cursor-based pagination |
|
||||
| [`+version-get`](references/lark-drive-version-get.md) | Download a specific historical version of a file |
|
||||
| [`+version-revert`](references/lark-drive-version-revert.md) | Revert a file to a specific historical version |
|
||||
| [`+version-delete`](references/lark-drive-version-delete.md) | Delete a specific historical version of a file |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/file/sheet/slides, also supports wiki URL resolving to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable/slides to a local file with limited polling; supports `--file-name` for local naming |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
|
||||
| [`+version-history`](references/lark-drive-version-history.md) | List historical versions of a file with only_tag=true and cursor-based pagination |
|
||||
| [`+version-get`](references/lark-drive-version-get.md) | Download a specific historical version of a file |
|
||||
| [`+version-revert`](references/lark-drive-version-revert.md) | Revert a file to a specific historical version |
|
||||
| [`+version-delete`](references/lark-drive-version-delete.md) | Delete a specific historical version of a file |
|
||||
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
|
||||
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
|
||||
| [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside cwd. |
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
把 `doc` / `docx` / `sheet` / `bitable` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
把 `doc` / `docx` / `sheet` / `bitable` / `slides` 导出到本地文件。这个 shortcut 内置有限轮询:
|
||||
|
||||
- 如果导出任务在轮询窗口内完成,会直接下载到本地目录
|
||||
- 如果轮询结束仍未完成,会返回 `ticket`、`ready=false`、`timed_out=true` 和 `next_command`
|
||||
@@ -39,6 +39,20 @@ lark-cli drive +export \
|
||||
--file-extension xlsx \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出幻灯片为 pptx
|
||||
lark-cli drive +export \
|
||||
--token "<SLIDES_TOKEN>" \
|
||||
--doc-type slides \
|
||||
--file-extension pptx \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出幻灯片为 pdf
|
||||
lark-cli drive +export \
|
||||
--token "<SLIDES_TOKEN>" \
|
||||
--doc-type slides \
|
||||
--file-extension pdf \
|
||||
--output-dir ./exports
|
||||
|
||||
# 指定本地文件名(会按导出格式自动补扩展名)
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -75,8 +89,8 @@ lark-cli drive +export \
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 源文档 token |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` |
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--file-name` | 否 | 覆盖默认本地文件名;如未带扩展名,会按 `--file-extension` 自动补齐 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
@@ -86,6 +100,8 @@ lark-cli drive +export \
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `pptx` 只支持 `slides`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
- shortcut 内部固定有限轮询:最多 10 次,每次间隔 5 秒
|
||||
- 轮询超时不是失败;会返回 `ticket`、`timed_out=true` 和 `next_command`,供后续继续查询
|
||||
|
||||
@@ -64,12 +64,27 @@ So `--markdown` is a convenience mode, not a full Markdown compatibility layer.
|
||||
- Block spacing and line breaks may be normalized during conversion.
|
||||
- Code blocks are preserved as code blocks.
|
||||
- Excess blank lines are compressed.
|
||||
- Only remote `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably.
|
||||
- Local paths in Markdown image syntax like `` are **not** auto-uploaded by `--markdown`.
|
||||
- If remote Markdown image handling fails, that image is removed with a warning.
|
||||
- Already-uploaded `img_xxx` image keys are the most reliable Markdown image input.
|
||||
- Local paths (e.g. ``) are **not** supported directly in `--markdown` and will not be auto-uploaded.
|
||||
- Remote URLs (`https://...`) will be auto-downloaded and uploaded at runtime; if the download or upload fails, the image is removed with a warning.
|
||||
|
||||
If you need exact output, use `--msg-type post --content ...` instead of `--markdown`.
|
||||
|
||||
### Image Constraint for `--markdown`
|
||||
|
||||
When using `--markdown` with images, prefer pre-uploading via `images.create` and referencing `` for predictable results. Remote URLs may work but are not guaranteed.
|
||||
|
||||
**Steps:**
|
||||
|
||||
```bash
|
||||
# 1. Upload image to get image_key
|
||||
lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png
|
||||
# Returns: {"image_key":"img_v3_xxxx"}
|
||||
|
||||
# 2. Use image_key in --markdown reply
|
||||
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Result\n\n\n\nSee above for details.'
|
||||
```
|
||||
|
||||
## Preserving Formatting
|
||||
|
||||
If the reply contains multiple lines, code blocks, indentation, tabs, or a lot of escaping, prefer `$'...'`.
|
||||
@@ -119,6 +134,11 @@ lark-cli im +messages-reply --message-id om_xxx --text "Let's discuss this" --re
|
||||
# Reply with basic Markdown (will be converted to post JSON)
|
||||
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Reply\n\n- item 1\n- item 2'
|
||||
|
||||
# Reply with Markdown containing an image (must pre-upload via images.create)
|
||||
lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png
|
||||
# Use the returned image_key
|
||||
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Screenshot\n\n\n\nConfirmed.'
|
||||
|
||||
# If you need exact post structure, send JSON directly
|
||||
lark-cli im +messages-reply --message-id om_xxx --msg-type post --content '{"zh_cn":{"title":"Reply","content":[[{"tag":"text","text":"Detailed content"}]]}}'
|
||||
|
||||
@@ -172,6 +192,7 @@ lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' -
|
||||
- Choosing `--markdown` when you actually need exact plain text. If exact line breaks and spacing matter, use `--text`, usually with `$'...'`.
|
||||
- Assuming `--markdown` supports all Markdown features. It does not; it is converted into a Feishu `post` payload and rewritten first.
|
||||
- Putting local image paths inside Markdown like ``. `--markdown` does not auto-upload those paths.
|
||||
- **Using local file paths inside Markdown image syntax** (e.g. ``) with `--markdown`. Local paths are not auto-uploaded and will not render as an image. Pre-upload via `images.create` to get an `image_key` instead.
|
||||
- Using `--content` without making the JSON match the effective `--msg-type`.
|
||||
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
|
||||
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
|
||||
@@ -226,3 +247,4 @@ The reply appears in the target message's thread and does not show up in the mai
|
||||
- Failures return error codes and messages
|
||||
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the reply is sent as the authorized end user
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
|
||||
|
||||
@@ -64,12 +64,27 @@ This means `--markdown` is convenient, but it is not a full-fidelity Markdown tr
|
||||
- Block spacing and line breaks may be normalized during conversion.
|
||||
- Code blocks are preserved as code blocks.
|
||||
- Excess blank lines are compressed.
|
||||
- Only `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably.
|
||||
- Local paths in Markdown image syntax like `` are **not** auto-uploaded by `--markdown`; they may be stripped during optimization.
|
||||
- If remote Markdown image download/upload fails, that image is removed with a warning.
|
||||
- Already-uploaded `img_xxx` image keys are the most reliable Markdown image input.
|
||||
- Local paths in Markdown image syntax like `` are **not** supported and will not be auto-uploaded.
|
||||
- Remote URLs (`https://...`) will be auto-downloaded and uploaded at runtime; if the download or upload fails, the image is removed with a warning.
|
||||
|
||||
If any of the above is unacceptable, do **not** use `--markdown`; use `--content` and provide the final JSON yourself.
|
||||
|
||||
### Image Constraint for `--markdown`
|
||||
|
||||
When using `--markdown` with images, prefer pre-uploading via `images.create` and referencing `` for predictable results. Remote URLs may work but are not guaranteed.
|
||||
|
||||
**Steps:**
|
||||
|
||||
```bash
|
||||
# 1. Upload image to get image_key
|
||||
lark-cli im images create --data '{"image_type":"message"}' --file ./diagram.png
|
||||
# Returns: {"image_key":"img_v3_xxxx"}
|
||||
|
||||
# 2. Use image_key in --markdown
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Report\n\n\n\nSee above for details.'
|
||||
```
|
||||
|
||||
## Preserving Formatting
|
||||
|
||||
If the message has multiple lines, indentation, code blocks, tabs, or many quotes/backslashes, prefer shell ANSI-C quoting with `$'...'`.
|
||||
@@ -118,6 +133,11 @@ lark-cli im +messages-send --chat-id oc_xxx --text $'Line 1\nLine 2\n indented
|
||||
# Send basic Markdown (will be converted to post JSON)
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Update\n\n- item 1\n- item 2'
|
||||
|
||||
# Send Markdown with an image (must pre-upload via images.create)
|
||||
lark-cli im images create --data '{"image_type":"message"}' --file ./screenshot.png
|
||||
# Use the returned image_key in the markdown content
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Status\n\n\n\nDone.'
|
||||
|
||||
# If you need exact post structure, send JSON directly
|
||||
lark-cli im +messages-send --chat-id oc_xxx --msg-type post --content '{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}'
|
||||
|
||||
@@ -178,6 +198,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
- Choosing `--markdown` when you actually need exact plain text. If exact line breaks and spacing matter, use `--text`, usually with `$'...'`.
|
||||
- Assuming `--markdown` supports all Markdown features. It does not; it is converted into a Feishu `post` payload and rewritten first.
|
||||
- Putting local image paths inside Markdown like ``. `--markdown` does not auto-upload those paths.
|
||||
- **Using local file paths inside Markdown image syntax** (e.g. ``) with `--markdown`. Local paths are not auto-uploaded and will not render as an image. Pre-upload via `images.create` to get an `image_key` instead.
|
||||
- Using `--content` without making the JSON match the effective `--msg-type`.
|
||||
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
|
||||
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
|
||||
@@ -227,3 +248,4 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
|
||||
- `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the message is sent as the authorized end user
|
||||
- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
- When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported
|
||||
|
||||
Reference in New Issue
Block a user