Files
Agent-System-Backend/internal/tools/fs_test.go
weichen a21a3b57b4 Route workspace Word delivery through the shared renderer
Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter.
Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally.
Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only.
Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
2026-10-15 19:41:14 +00:00

182 lines
6.8 KiB
Go
Raw Permalink 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)
}
}
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, guidance string }{
{"fs_write_file", `{"path":"a.docx","content":"plain"}`, "render_document"},
{"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, args.guidance) {
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)
}
}