From da149e66baac1df9f1b42bc7de9f9f26cdd07076 Mon Sep 17 00:00:00 2001 From: fangshuyu Date: Wed, 22 Jul 2026 18:52:59 +0800 Subject: [PATCH 1/3] fix: validate unsafe docs write inputs --- shortcuts/doc/doc_errors_test.go | 32 +++++++++++++ shortcuts/doc/docs_create_v2.go | 24 +++++++++- shortcuts/doc/docs_update_v2.go | 31 ++++++++++++- tests/cli_e2e/docs/docs_update_dryrun_test.go | 45 +++++++++++++++++++ 4 files changed, 129 insertions(+), 3 deletions(-) diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go index 62ace6582d..22c0c6664f 100644 --- a/shortcuts/doc/doc_errors_test.go +++ b/shortcuts/doc/doc_errors_test.go @@ -193,6 +193,12 @@ func TestValidateCreateV2Contract(t *testing.T) { wantParam: "", // mutual exclusion: enumerated in Params wantParams: []string{"--parent-token", "--parent-position"}, }, + { + name: "title flag conflicts with XML title element", + str: map[string]string{"title": "Flag title", "doc-format": "xml", "content": "Content title

body

"}, + wantParam: "", + wantParams: []string{"--title", "--content"}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -356,11 +362,26 @@ func TestValidateUpdateV2Contract(t *testing.T) { str: map[string]string{"doc": testDocxToken, "command": "str_replace"}, wantParam: "--pattern", }, + { + name: "XML str_replace rejects multiline pattern", + str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"}, + wantParam: "--pattern", + }, { name: "block_delete without block id", str: map[string]string{"doc": testDocxToken, "command": "block_delete"}, wantParam: "--block-id", }, + { + name: "block_delete rejects empty ID", + str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"}, + wantParam: "--block-id", + }, + { + name: "block_delete rejects duplicate ID", + str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"}, + wantParam: "--block-id", + }, { name: "block_insert_after without block id", str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"}, @@ -425,3 +446,14 @@ func TestValidateUpdateV2Contract(t *testing.T) { }) } } + +func TestValidateUpdateV2RejectsNoOpStrReplace(t *testing.T) { + rt := docValidateRuntime(t, map[string]string{ + "doc": testDocxToken, + "command": "str_replace", + "pattern": "already final", + "content": "already final", + }, nil, nil) + err := validateUpdateV2(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--pattern", "--content") +} diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index e5c5e36724..758e05c9ed 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -16,7 +16,7 @@ import ( // v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path. func v2CreateFlags() []common.Flag { return []common.Flag{ - {Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as ... so the title wins over later content titles"}, + {Name: "title", Desc: "document title; the CLI prepends it to --content as .... In XML mode, do not combine it with a element in --content"}, {Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, {Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, {Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}}, @@ -45,6 +45,12 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { if runtime.Str("content") == "" && title == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content") } + if title != "" && runtime.Str("doc-format") == "xml" && xmlFragmentContainsTitle(runtime.Str("content")) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title conflicts with a <title> element in XML --content; use exactly one title source").WithParams( + errs.InvalidParam{Name: "--title", Reason: "conflicts with the XML <title> element in --content"}, + errs.InvalidParam{Name: "--content", Reason: "already contains an XML <title> element"}, + ) + } if runtime.Str("content") != "" { _, err := resolveDocsV2ContentReferenceMap(runtime) return err @@ -52,6 +58,22 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { return nil } +// xmlFragmentContainsTitle reports whether a DocxXML fragment contains a title +// element. Wrapping the fragment allows the XML decoder to inspect multiple +// top-level blocks without changing the content sent to the service. +func xmlFragmentContainsTitle(content string) bool { + decoder := xml.NewDecoder(strings.NewReader("<root>" + content + "</root>")) + for { + token, err := decoder.Token() + if err != nil { + return false + } + if start, ok := token.(xml.StartElement); ok && strings.EqualFold(start.Name.Local, "title") { + return true + } + } +} + func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { body, err := buildCreateBodyWithHTML5ReferenceMap(runtime) if err != nil { diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index 755b35c70f..7802f91b6b 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag { {Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, {Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, - {Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"}, - {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"}, + {Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text; pattern must differ from --content"}, + {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"}, {Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"}, {Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"}, } @@ -73,10 +73,22 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { if pattern == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern") } + if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern") + } + if pattern == content { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "str_replace --pattern and --content are identical, so the request would make no document changes").WithParams( + errs.InvalidParam{Name: "--pattern", Reason: "identical to --content"}, + errs.InvalidParam{Name: "--content", Reason: "identical to --pattern"}, + ) + } case "block_delete": if blockID == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id") } + if err := validateBlockDeleteIDs(blockID); err != nil { + return err + } case "block_insert_after": if blockID == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id") @@ -124,6 +136,21 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { return nil } +func validateBlockDeleteIDs(raw string) error { + seen := make(map[string]struct{}) + for _, part := range strings.Split(raw, ",") { + blockID := strings.TrimSpace(part) + if blockID == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id") + } + if _, ok := seen[blockID]; ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id") + } + seen[blockID] = struct{}{} + } + return nil +} + func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index 5219fa9f5a..ffaeff35b2 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -225,3 +225,48 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) { require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out) require.Equal(t, "<title>Dry Run & Title\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out) } + +func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + tests := []struct { + name string + args []string + want string + }{ + { + name: "duplicate XML title", + args: []string{"docs", "+create", "--title", "Flag title", "--content", "Content title

body

", "--dry-run"}, + want: "use exactly one title source", + }, + { + name: "multiline XML str_replace", + args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"}, + want: "must be inline", + }, + { + name: "no-op str_replace", + args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "already final", "--content", "already final", "--dry-run"}, + want: "would make no document changes", + }, + { + name: "duplicate block delete ID", + args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"}, + want: "duplicate ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"}) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want) + }) + } +} From 5b67085b322465810796139c0d3f9c8276d8d10c Mon Sep 17 00:00:00 2001 From: fangshuyu Date: Wed, 22 Jul 2026 19:07:04 +0800 Subject: [PATCH 2/3] fix: preserve docs title compatibility --- shortcuts/doc/doc_errors_test.go | 17 ---- shortcuts/doc/docs_create_test.go | 40 +++++++++ shortcuts/doc/docs_create_v2.go | 85 ++++++++++++++----- shortcuts/doc/docs_update_v2.go | 8 +- tests/cli_e2e/docs/docs_update_dryrun_test.go | 32 ++++--- 5 files changed, 125 insertions(+), 57 deletions(-) diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go index 22c0c6664f..a7a5f5d7cd 100644 --- a/shortcuts/doc/doc_errors_test.go +++ b/shortcuts/doc/doc_errors_test.go @@ -193,12 +193,6 @@ func TestValidateCreateV2Contract(t *testing.T) { wantParam: "", // mutual exclusion: enumerated in Params wantParams: []string{"--parent-token", "--parent-position"}, }, - { - name: "title flag conflicts with XML title element", - str: map[string]string{"title": "Flag title", "doc-format": "xml", "content": "Content title

body

"}, - wantParam: "", - wantParams: []string{"--title", "--content"}, - }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -446,14 +440,3 @@ func TestValidateUpdateV2Contract(t *testing.T) { }) } } - -func TestValidateUpdateV2RejectsNoOpStrReplace(t *testing.T) { - rt := docValidateRuntime(t, map[string]string{ - "doc": testDocxToken, - "command": "str_replace", - "pattern": "already final", - "content": "already final", - }, nil, nil) - err := validateUpdateV2(context.Background(), rt) - assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--pattern", "--content") -} diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go index f5002ebb13..97d5443a2b 100644 --- a/shortcuts/doc/docs_create_test.go +++ b/shortcuts/doc/docs_create_test.go @@ -17,6 +17,46 @@ import ( // ── V2 (OpenAPI) tests ── +func TestStripTopLevelXMLTitles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want string + }{ + { + name: "single title", + content: "Content title

body

", + want: "

body

", + }, + { + name: "multiple titles", + content: "First\n

body

\nSecond", + want: "

body

", + }, + { + name: "nested title is preserved", + content: "Nested

body

", + want: "Nested

body

", + }, + { + name: "malformed XML is preserved", + content: "Content title

A & B

", + want: "Content title

A & B

", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := stripTopLevelXMLTitles(tt.content); got != tt.want { + t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want) + } + }) + } +} + func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) { t.Parallel() diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 758e05c9ed..1a594dc10a 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -7,6 +7,8 @@ import ( "bytes" "context" "encoding/xml" + "errors" + "io" "strings" "github.com/larksuite/cli/errs" @@ -16,7 +18,7 @@ import ( // v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path. func v2CreateFlags() []common.Flag { return []common.Flag{ - {Name: "title", Desc: "document title; the CLI prepends it to --content as .... In XML mode, do not combine it with a element in --content"}, + {Name: "title", Desc: "document title; the CLI prepends it to --content as <title>.... In XML mode, top-level elements in --content are removed so this flag wins without duplicate-title warnings"}, {Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, {Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, {Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}}, @@ -45,12 +47,6 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { if runtime.Str("content") == "" && title == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content") } - if title != "" && runtime.Str("doc-format") == "xml" && xmlFragmentContainsTitle(runtime.Str("content")) { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title conflicts with a <title> element in XML --content; use exactly one title source").WithParams( - errs.InvalidParam{Name: "--title", Reason: "conflicts with the XML <title> element in --content"}, - errs.InvalidParam{Name: "--content", Reason: "already contains an XML <title> element"}, - ) - } if runtime.Str("content") != "" { _, err := resolveDocsV2ContentReferenceMap(runtime) return err @@ -58,22 +54,6 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { return nil } -// xmlFragmentContainsTitle reports whether a DocxXML fragment contains a title -// element. Wrapping the fragment allows the XML decoder to inspect multiple -// top-level blocks without changing the content sent to the service. -func xmlFragmentContainsTitle(content string) bool { - decoder := xml.NewDecoder(strings.NewReader("<root>" + content + "</root>")) - for { - token, err := decoder.Token() - if err != nil { - return false - } - if start, ok := token.(xml.StartElement); ok && strings.EqualFold(start.Name.Local, "title") { - return true - } - } -} - func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { body, err := buildCreateBodyWithHTML5ReferenceMap(runtime) if err != nil { @@ -130,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) if title == "" { return content } + if runtime.Str("doc-format") == "xml" { + content = stripTopLevelXMLTitles(content) + } titleTag := "<title>" + escapeDocTitleText(title) + "" if content == "" { @@ -138,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) return titleTag + "\n" + content } +type docContentRange struct { + start int64 + end int64 +} + +// stripTopLevelXMLTitles preserves the established --title-wins contract while +// avoiding duplicate-title warnings from XML content. If the fragment is not +// well-formed XML, it is left untouched for the service to diagnose. +func stripTopLevelXMLTitles(content string) string { + const wrapperStart = "" + wrapped := wrapperStart + content + "" + decoder := xml.NewDecoder(strings.NewReader(wrapped)) + wrapperLen := int64(len(wrapperStart)) + depth := 0 + activeStart := int64(-1) + ranges := make([]docContentRange, 0, 1) + + for { + tokenStart := decoder.InputOffset() + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return content + } + + switch value := token.(type) { + case xml.StartElement: + if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" { + activeStart = tokenStart - wrapperLen + } + depth++ + case xml.EndElement: + depth-- + if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" { + ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen}) + activeStart = -1 + } + } + } + + if len(ranges) == 0 { + return content + } + + var result strings.Builder + cursor := int64(0) + for _, item := range ranges { + result.WriteString(content[int(cursor):int(item.start)]) + cursor = item.end + } + result.WriteString(content[int(cursor):]) + return strings.TrimSpace(result.String()) +} + func escapeDocTitleText(title string) string { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(title)) diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index 7802f91b6b..1e08b6b814 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -35,7 +35,7 @@ func v2UpdateFlags() []common.Flag { {Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, {Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, - {Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text; pattern must differ from --content"}, + {Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"}, {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"}, {Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"}, {Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"}, @@ -76,12 +76,6 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") { return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern") } - if pattern == content { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "str_replace --pattern and --content are identical, so the request would make no document changes").WithParams( - errs.InvalidParam{Name: "--pattern", Reason: "identical to --content"}, - errs.InvalidParam{Name: "--content", Reason: "identical to --pattern"}, - ) - } case "block_delete": if blockID == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id") diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index ffaeff35b2..39da9b8622 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -226,6 +226,28 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) { require.Equal(t, "Dry Run & Title\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out) } +func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--title", "Flag title", + "--content", "Content title

body

", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Equal(t, "Flag title\n

body

", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String()) +} + func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) { t.Setenv("LARKSUITE_CLI_APP_ID", "app") t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") @@ -239,21 +261,11 @@ func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) { args []string want string }{ - { - name: "duplicate XML title", - args: []string{"docs", "+create", "--title", "Flag title", "--content", "Content title

body

", "--dry-run"}, - want: "use exactly one title source", - }, { name: "multiline XML str_replace", args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"}, want: "must be inline", }, - { - name: "no-op str_replace", - args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "already final", "--content", "already final", "--dry-run"}, - want: "would make no document changes", - }, { name: "duplicate block delete ID", args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"}, From 15263efe3045463181fa2f197fc4d263b43fb6ff Mon Sep 17 00:00:00 2001 From: fangshuyu Date: Wed, 22 Jul 2026 19:09:46 +0800 Subject: [PATCH 3/3] fix: normalize batch delete block IDs --- shortcuts/doc/docs_update_v2.go | 11 +++++++++++ tests/cli_e2e/docs/docs_update_dryrun_test.go | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index 1e08b6b814..987dca2bf9 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -145,6 +145,14 @@ func validateBlockDeleteIDs(raw string) error { return nil } +func normalizeBlockDeleteIDs(raw string) string { + parts := strings.Split(raw, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return strings.Join(parts, ",") +} + func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) @@ -220,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} body["pattern"] = v } if blockID != "" { + if cmd == "block_delete" { + blockID = normalizeBlockDeleteIDs(blockID) + } body["block_id"] = blockID } if v := runtime.Str("src-block-ids"); v != "" { diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index 39da9b8622..33ba745499 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { "docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", - "--block-id", "blkA,blkB,blkC", + "--block-id", "blkA, blkB, blkC", "--dry-run", }, wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"}, + wantBody: map[string]any{"block_id": "blkA,blkB,blkC"}, }, { name: "history list",