Files
Agent-System-Backend/internal/tools/fs_test.go
T
weichen 370ae1a280 Generate styled Word deliverables and pin artifact cards in chat
Why: docx deliverables were plain text saved with a .docx name (no
headings/tables/styles), and the artifact_ref part was marked Process
so the deliverable vanished into the collapsed process section — the
user saw only model-written text like "📄 xxx.docx 工件 ID …" instead
of an openable, formatted document delivered in the conversation.

Evidence:
- new internal/docgen: Markdown -> styled OOXML (Title/Heading1-4,
  numbered+bullet lists with numbering.xml, GFM tables with shaded
  header, quotes, code blocks, bold/italic/strike, real hyperlinks);
  zero third-party deps; tests cover zip structure, XML well-formedness,
  numbering refs, hyperlink rels, escaping, plain-text fallback
- ingest round-trip test proves generated docx parses back to blocks
- new write_docx builtin tool (markdown in -> styled .docx artifact);
  write_artifact prompt now routes Word docs to it; task.doc-minutes
  output contract requires write_docx delivery
- llm.go: artifact refs collected as deliverables and appended as
  content parts AFTER the final text (pinned tail, not process); mock
  #dogfood path generates a real styled docx; runmgr skips string
  indexing for binary office mimes
- go test ./... 19 packages ok (incl. new e2e asserting <w:tbl> and
  title text inside the artifact served by the API)

Impact: model-written documents open in Word/WPS with real layout and
preview with formatting (mammoth maps Heading1-4 to h1-h4); every tool
artifact now lands as a visible in-chat card; no behavior change for
text artifacts.

Refs: task feature/deliverable-docx-r1
2026-10-01 02:48:49 +00:00

164 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fs 工具族测试:沙箱隔离 / 路径穿越负例 / 大小上限 / Hidden 门禁 / 工件登记不变式。
package tools
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
type fakeFSCtx struct {
root string
owner string
conv string
fsToolsOn bool
written []string // 登记的工件名
lastContent []byte // 最近一次写入的工件字节
lastMime string
}
func (c *fakeFSCtx) ProjectID() string { return "p1" }
func (c *fakeFSCtx) ConversationID() string { return c.conv }
func (c *fakeFSCtx) OwnerID() string { return c.owner }
func (c *fakeFSCtx) WorkspaceRoot() string { return c.root }
func (c *fakeFSCtx) SettingBool(key string, def bool) bool {
if key == "runtime.fsTools" {
return c.fsToolsOn
}
return def
}
func (c *fakeFSCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
func (c *fakeFSCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
c.written = append(c.written, name)
c.lastContent = content
c.lastMime = mime
return core.Artifact{ID: "art_" + name, Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}, nil
}
func (c *fakeFSCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil }
func (c *fakeFSCtx) PeerConversationMessages(string, int) ([]core.Message, error) { return nil, nil }
func newFSRegistry(t *testing.T) *Registry {
t.Helper()
r := NewRegistry()
if err := r.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
if err := RegisterFSTools(r); err != nil {
t.Fatal(err)
}
return r
}
func call(t *testing.T, r *Registry, rc *fakeFSCtx, name, args string) core.ToolCallResult {
t.Helper()
return r.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)})
}
func TestFSToolsHiddenAndGated(t *testing.T) {
r := newFSRegistry(t)
// 可见性:Hidden 工具不在 List()(List 本身不过滤——过滤在 server/runmgr 层),
// 但描述带 Hidden 标记。
for _, d := range r.List() {
if strings.HasPrefix(d.Name, "fs_") && !d.Hidden {
t.Errorf("%s should be Hidden", d.Name)
}
}
// 执行门禁:runtime.fsTools 关闭时 fail-closed。
rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: false}
res := call(t, r, rc, "fs_write_file", `{"path":"a.md","content":"x"}`)
if res.Status != "error" || !strings.Contains(res.Output, "reserved capability") {
t.Errorf("gate off should reject, got %s %s", res.Status, res.Output)
}
}
func TestFSWriteReadListRoundtrip(t *testing.T) {
r := newFSRegistry(t)
root := t.TempDir()
rc := &fakeFSCtx{root: root, owner: "o1", conv: "c1", fsToolsOn: true}
res := call(t, r, rc, "fs_write_file", `{"path":"notes/纪要.md","content":"# 会议纪要\n内容"}`)
if res.Status != "ok" {
t.Fatalf("write failed: %s", res.Output)
}
// 不变式:写文件同步登记工件。
if len(rc.written) != 1 || rc.written[0] != "纪要.md" {
t.Errorf("artifact registration missing: %v", rc.written)
}
// 落盘位置在会话沙箱内。
if _, err := os.Stat(filepath.Join(root, "o1", "c1", "notes", "纪要.md")); err != nil {
t.Fatalf("sandbox file missing: %v", err)
}
res = call(t, r, rc, "fs_read_file", `{"path":"notes/纪要.md"}`)
if res.Status != "ok" || !strings.Contains(res.Output, "会议纪要") {
t.Fatalf("read failed: %s %s", res.Status, res.Output)
}
res = call(t, r, rc, "fs_list_dir", `{}`)
if res.Status != "ok" || !strings.Contains(res.Output, "notes") {
t.Fatalf("list failed: %s %s", res.Status, res.Output)
}
}
func TestFSPathTraversalRejected(t *testing.T) {
r := newFSRegistry(t)
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "outside"), 0o755); err != nil {
t.Fatal(err)
}
rc := &fakeFSCtx{root: root, owner: "o1", conv: "c1", fsToolsOn: true}
negatives := []string{
`{"path":"../escape.md","content":"x"}`,
`{"path":"..\\..\\escape.md","content":"x"}`,
`{"path":"/etc/passwd","content":"x"}`,
`{"path":"a/../../escape.md","content":"x"}`,
`{"path":"a/../../../o2/c2/steal.md","content":"x"}`,
}
for _, args := range negatives {
if res := call(t, r, rc, "fs_write_file", args); res.Status != "error" {
t.Errorf("traversal write should fail: %s -> %s", args, res.Output)
}
}
// 读穿越同样拒绝。
if res := call(t, r, rc, "fs_read_file", `{"path":"../../secret"}`); res.Status != "error" {
t.Errorf("traversal read should fail: %s", res.Output)
}
// 符号链接逃逸:沙箱内软链指向根外,拒绝覆写。
outside := filepath.Join(root, "outside", "target.txt")
os.WriteFile(outside, []byte("keep"), 0o644)
link := filepath.Join(root, "o1", "c1", "link.md")
os.MkdirAll(filepath.Dir(link), 0o755)
os.Symlink(outside, link)
if res := call(t, r, rc, "fs_write_file", `{"path":"link.md","content":"pwn"}`); res.Status != "error" {
t.Errorf("symlink escape should fail: %s", res.Output)
}
if b, _ := os.ReadFile(outside); string(b) != "keep" {
t.Errorf("outside file was modified via symlink")
}
}
func TestFSSizeLimits(t *testing.T) {
r := newFSRegistry(t)
rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true}
big := strings.Repeat("a", fsMaxWrite+1)
args, _ := json.Marshal(map[string]string{"path": "big.txt", "content": big})
if res := call(t, r, rc, "fs_write_file", string(args)); res.Status != "error" {
t.Errorf("oversize write should fail")
}
// 读上限:直接写一个大文件进沙箱再读。
os.MkdirAll(filepath.Join(rc.root, "o1", "c1"), 0o755)
os.WriteFile(filepath.Join(rc.root, "o1", "c1", "big.bin"), make([]byte, fsMaxRead+1), 0o644)
if res := call(t, r, rc, "fs_read_file", `{"path":"big.bin"}`); res.Status != "error" {
t.Errorf("oversize read should fail")
}
}
func TestFSUnconfiguredRoot(t *testing.T) {
r := newFSRegistry(t)
rc := &fakeFSCtx{root: "", owner: "o1", conv: "c1", fsToolsOn: true}
if res := call(t, r, rc, "fs_list_dir", `{}`); res.Status != "error" {
t.Errorf("unconfigured root should fail: %s", res.Output)
}
}