From 3e5699c595eebc57d8059be277fcc7222d115306 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 04:12:12 +0000 Subject: [PATCH] Reject pseudo-binary Office writes and index extracted text Why: string writes of .docx/.xlsx/.pdf produce files that will not open, and generated binary artifacts were skipped by search entirely. Evidence: go test ./... 19 packages ok. Office extensions are refused with a pointer to run_python plus register_artifact. A docgen docx is searchable after WriteArtifact; unparseable bytes are not indexed. Impact: write_artifact and fs_write_file no longer accept those extensions. Text artifacts index as before. Merge after the interpreter PR so the error text names tools that exist. Refs: task feature/binary-doc-index; supersedes Dev #99 / Backend #27 Co-authored-by: Cursor --- internal/runmgr/exec_tool_artifact_test.go | 31 ++++++++++++++++++++++ internal/runmgr/runmgr.go | 31 ++++++++++++++++------ internal/tools/fs.go | 14 ++++++++++ internal/tools/fs_test.go | 18 +++++++++++++ internal/tools/registry.go | 3 +++ 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/internal/runmgr/exec_tool_artifact_test.go b/internal/runmgr/exec_tool_artifact_test.go index b2ed6e4..f252cf9 100644 --- a/internal/runmgr/exec_tool_artifact_test.go +++ b/internal/runmgr/exec_tool_artifact_test.go @@ -10,6 +10,7 @@ import ( "testing" "agent-system-backend/internal/bus" + "agent-system-backend/internal/docgen" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" @@ -71,3 +72,33 @@ func TestExecuteToolNoArtifactByDefault(t *testing.T) { t.Errorf("无产物工具不应携带 Artifact: %+v", res) } } + +func TestGeneratedDocxIsSearchable(t *testing.T) { + c, st := newExecEnv(t) + const marker = "zebraindexword" + docx, err := docgen.Build("纪要", "结论:"+marker) + if err != nil { + t.Fatal(err) + } + mime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + if _, err := c.WriteArtifact("note.docx", "document", mime, docx); err != nil { + t.Fatal(err) + } + hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: marker, ProjectID: c.run.ProjectID, TopK: 5}) + if err != nil { + t.Fatal(err) + } + if len(hits) == 0 { + t.Fatal("generated docx text was not indexed") + } + if _, err := c.WriteArtifact("bad.docx", "document", mime, []byte("notazipmarker")); err != nil { + t.Fatal(err) + } + hits, err = st.SearchKnowledge("alice", core.RetrievalQuery{Text: "notazipmarker", ProjectID: c.run.ProjectID, TopK: 5}) + if err != nil { + t.Fatal(err) + } + if len(hits) != 0 { + t.Fatalf("unparseable docx bytes should not be indexed, got %d hits", len(hits)) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 2490904..b546947 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -13,6 +13,7 @@ import ( "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" + "agent-system-backend/internal/ingest" "agent-system-backend/internal/profiles" "agent-system-backend/internal/runtime" "agent-system-backend/internal/settings" @@ -352,14 +353,15 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil { return art, err } - // 工件文本进检索索引(管线分块+全文+可选向量;未注入回落整文档单块)。 - // 二进制 Office/PDF 不进字符串索引(zip 字节当文本是垃圾数据); - // docx 的可检索文本由 ingest 管线在上传路径解析,生成路径暂不索引。 - if kind == "document" && !binaryDocMime(mime) && len(content) < 200000 { - if c.mgr.indexer != nil { - _ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, string(content)) - } else { - _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)}) + // 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库; + // 抽不到则跳过,避免把 ZIP 字节当文本索引。 + if kind == "document" && len(content) < 200000 { + if text := indexTextOf(name, mime, content); text != "" { + if c.mgr.indexer != nil { + _ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text) + } else { + _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}) + } } } return art, nil @@ -381,6 +383,19 @@ func binaryDocMime(mime string) bool { return false } +// indexTextOf 决定工件进检索索引的文本。普通文本原样返回;二进制文档 mime +// 先走 ingest 抽文本,解析失败或为空则返回空串(跳过索引)。 +func indexTextOf(name, mime string, content []byte) string { + if !binaryDocMime(mime) { + return string(content) + } + doc := ingest.Parse(name, mime, content) + if len(doc.Warnings) > 0 || len(doc.Blocks) == 0 { + return "" + } + return strings.TrimSpace(doc.PlainText()) +} + func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { if q.TopK <= 0 { q.TopK = int(c.eff.GetNumber("retrieval.topK", 5)) diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 4f4b57d..d06ac90 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -113,6 +113,9 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { if a.Path == "" { return "", fmt.Errorf("fs_write_file: path required") } + if err := rejectBinaryOfficeWrite(a.Path); err != nil { + return "", err + } if len(a.Content) > fsMaxWrite { return "", fmt.Errorf("fs_write_file: 内容超过 2MB 上限") } @@ -206,3 +209,14 @@ func mimeOfExt(name string) string { return "text/plain; charset=utf-8" } } + +// rejectBinaryOfficeWrite 拒绝把字符串直接写成伪二进制 Office/PDF。 +// 带排版的文件由 run_python 生成后再 register_artifact 登记。 +func rejectBinaryOfficeWrite(name string) error { + switch strings.ToLower(filepath.Ext(name)) { + case ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf": + return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件。请用 run_python 生成到工作区后,用 register_artifact 登记", filepath.Ext(name)) + default: + return nil + } +} diff --git a/internal/tools/fs_test.go b/internal/tools/fs_test.go index fd91452..0853014 100644 --- a/internal/tools/fs_test.go +++ b/internal/tools/fs_test.go @@ -161,3 +161,21 @@ func TestFSUnconfiguredRoot(t *testing.T) { t.Errorf("unconfigured root should fail: %s", res.Output) } } + +func TestBinaryOfficeWriteRejected(t *testing.T) { + r := newFSRegistry(t) + rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true} + for _, args := range []struct{ name, body string }{ + {"fs_write_file", `{"path":"a.docx","content":"plain"}`}, + {"fs_write_file", `{"path":"a.pdf","content":"plain"}`}, + {"write_artifact", `{"name":"a.xlsx","content":"plain"}`}, + } { + res := call(t, r, rc, args.name, args.body) + if res.Status != "error" || !strings.Contains(res.Output, "run_python") { + t.Errorf("%s %s: %s %s", args.name, args.body, res.Status, res.Output) + } + } + if res := call(t, r, rc, "write_artifact", `{"name":"a.md","content":"plain"}`); res.Status != "ok" { + t.Fatalf("markdown write should stay allowed: %s", res.Output) + } +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 00422e6..62a6295 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -164,6 +164,9 @@ func (r *Registry) RegisterBuiltin() error { if a.Name == "" { return "", fmt.Errorf("write_artifact: name required") } + if err := rejectBinaryOfficeWrite(a.Name); err != nil { + return "", err + } kind := a.Kind if kind == "" { kind = "document"