Compare commits

..

7 Commits

Author SHA1 Message Date
liangshuo-1
ce5b4f24e1 chore(release): v1.0.39 (#1052)
Change-Id: I06bca4f3aedec1adee9ecd3d060c333cc6dd301e
2026-05-22 21:10:35 +08:00
MaxHuang22
4b2223194b fix: add 22 new scope entries to scope priorities (#1050)
Change-Id: I2e7bb2e2971bfb071c3976d349b2d2bc4cc485ae
2026-05-22 19:48:08 +08:00
zgz2048
4582dfd281 docs(base): update location full_address guidance (#754) 2026-05-22 18:05:35 +08:00
ethan-zhx
5c01a7f7f0 feat(slides): export slides (#988)
Change-Id: Ice3e8784e78986d427c4c94664e1e5edff2a4fcd
2026-05-22 17:19:49 +08:00
raistlin042
d5d2fee848 chore(apps): refine lark-apps skill description and surface (#1040)
- description: switch from trigger-word enumeration to a general
  principle (any HTML artifact intended to be independently accessible
  falls under this skill; defer the deploy-vs-demo decision to the
  skill body)
- surface apps +access-scope-get in prerequisites list and Shortcuts
  table so agents can find the read side of access-scope
- add "writing HTML hard constraints" section: index.html is the
  required entry filename, --path cannot equal cwd (both are CLI-side
  hard rejects that previously only lived in the html-publish ref)
2026-05-22 16:39:36 +08:00
hGrany
ffcf7781b4 feat(sidecar): support multi-client identity isolation in server-demo (#934)
* feat(sidecar): support multi-client identity isolation in server-demo

When multiple CLI sandbox environments share a single sidecar instance,
user tokens (UAT) were not isolated -- the last user to log in would
overwrite previous users' tokens, causing identity cross-contamination.

This change introduces per-client HMAC key isolation:
- Each client gets a unique client-*.key file for data-plane HMAC signing,
  allowing the sidecar to identify request origin.
- A new auth_bridge.go handles management endpoints (login/poll/status)
  with explicit client-to-feishuOpenId binding.
- User token resolution is strictly bound to the matched client -- no
  fallback to other users' tokens when a client has no mapping.
- The shared proxy.key is reused across restarts instead of regenerated,
  fixing a race condition when multiple sidecar instances start together.

Wire protocol (sidecar package) is unchanged; existing single-client
deployments are fully backward compatible.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address review feedback on filesystem and safety

- Replace os.ReadFile/WriteFile/ReadDir with vfs.* equivalents for test
  mockability, consistent with project coding guidelines.
- Limit auth bridge request body to 64KB to prevent memory exhaustion.
- Log errors in saveUserMap instead of silently discarding them.
- Reject client keys that collide with the shared proxy key.
- Reject duplicate client keys instead of silently overwriting.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): remove workspace-specific naming and backward compat

- parseClientID: only accept "client_id" field, remove legacy fallback
- loadClientKeys: scan all *.key (excluding proxy.key), no prefix required
- Remove legacy file migration logic in newAuthBridge
- Update flag description to reflect generic key scanning

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): extract multi-tenant demo and add unit tests

Address review feedback from sang-neo03:

1. Extract multi-client code into sidecar/server-multi-tenant-demo/,
   keeping server-demo as the minimal single-tenant reference.

2. Add unit tests for the isolation guarantee:
   - loadClientKeys: shared-key collision and duplicate keyHex are skipped
   - verifyWithClientKeys: correct client matched, unknown key rejected
   - loadUserMap/saveUserMap: round-trip persistence across restart

3. Cross-link READMEs between server-demo and server-multi-tenant-demo.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* docs(sidecar): rewrite multi-tenant demo README with problem statement and client guide

- Explain the multi-app credential isolation problem (app_secret must
  not be exposed to client environments)
- Document typical deployment topology with multiple sidecar instances
- Add complete client setup guide: env vars, multi-app switching, login
  flow, and end-to-end workflow example
- Document design decisions and management endpoint details

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address CodeRabbit review feedback on tests and docs

- Make TestProxyHandler_AcceptsAllowedAuthHeaders fully offline by using
  httptest.NewTLSServer instead of depending on open.feishu.cn
- Isolate TestRun_RejectsSelfProxy config state with t.Setenv and temp dirs
- Check os.MkdirAll error in test fixture setup
- Add language identifiers to fenced code blocks (MD040)
- Validate user-supplied CLI paths with validate.SafeInputPath

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

---------

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)
2026-05-22 15:25:00 +08:00
liujiashu-shiro
fbe4cc689a feat(im): support Markdown image rendering in post content (#893)
add documentation for sending Markdown images, and align image handling guidance with actual runtime behavior
2026-05-22 10:44:10 +08:00
30 changed files with 2726 additions and 389 deletions

View File

@@ -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

View File

@@ -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"
}
]

View File

@@ -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"

View File

@@ -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)"},

View File

@@ -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 ""
}

View File

@@ -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)
}
}

View File

@@ -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"},

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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

View 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

View 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
}

View 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
}

View 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 ""
}

View 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
}

View 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
}

View 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"])
}
}

View 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
}

View File

@@ -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 时,如果用户已经明确要求删除且目标明确,可以直接执行;只有目标不明确时才先追问

View File

@@ -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**(用户明示部署 / 分享时直接调;仅说"可演示"时先问用户是否要部署再调) |

View File

@@ -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` |

View File

@@ -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` 一次追加多个附件,不能用普通记录操作接口写附件值。

View 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`*

View File

@@ -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` | 只有完全匹配目标选项名的值会转成对应选项;没匹配上的值会被丢弃 |

View File

@@ -464,6 +464,8 @@
{ "type": "location", "name": "位置" }
```
写入必须使用 `{lng,lat}`。location 读回会包含 `full_address`;筛选和 `location -> text` 类型转换按 `full_address` 字符串处理,只有公式能访问坐标。
```json
{ "type": "checkbox", "name": "完成" }
```

View File

@@ -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`
用数字:

View File

@@ -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

View File

@@ -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`,供后续继续查询

View File

@@ -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 `![x](./a.png)` 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. `![x](./a.png)`) 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 `![alt](img_xxx)` 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![diagram](img_v3_xxxx)\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![screenshot](img_v3_xxxx)\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 `![x](./a.png)`. `--markdown` does not auto-upload those paths.
- **Using local file paths inside Markdown image syntax** (e.g. `![x](./a.png)`) 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

View File

@@ -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 `![x](./a.png)` 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 `![x](./a.png)` 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 `![alt](img_xxx)` 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![diagram](img_v3_xxxx)\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![screenshot](img_v3_xxxx)\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 `![x](./a.png)`. `--markdown` does not auto-upload those paths.
- **Using local file paths inside Markdown image syntax** (e.g. `![x](./a.png)`) 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