first commit
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
func runRead(t *testing.T, tool *ReadTool, args map[string]any) (agentcore.AgentToolResult, bool) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
res, gerr := tool.Execute(context.Background(), "call-1", raw, nil)
|
||||
if gerr != nil {
|
||||
t.Fatalf("execute returned go error: %v", gerr)
|
||||
}
|
||||
return res, false
|
||||
}
|
||||
|
||||
func resultText(res agentcore.AgentToolResult) string {
|
||||
var b strings.Builder
|
||||
for _, c := range res.Content {
|
||||
if tc, ok := c.(agentcore.TextContent); ok {
|
||||
b.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestReadToolBasic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hello.txt")
|
||||
if err := os.WriteFile(path, []byte("line one\nline two\nline three\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "hello.txt"})
|
||||
text := resultText(res)
|
||||
if !strings.Contains(text, "line one") || !strings.Contains(text, "line three") {
|
||||
t.Errorf("missing content: %q", text)
|
||||
}
|
||||
// Line numbers present.
|
||||
if !strings.Contains(text, "1\tline one") || !strings.Contains(text, "3\tline three") {
|
||||
t.Errorf("missing line numbers: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolOffsetLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var sb strings.Builder
|
||||
for i := 1; i <= 10; i++ {
|
||||
sb.WriteString("row\n")
|
||||
}
|
||||
path := filepath.Join(dir, "rows.txt")
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "rows.txt", "offset": 3, "limit": 2})
|
||||
text := resultText(res)
|
||||
// Should include line numbers 3 and 4, not 1,2,5.
|
||||
if !strings.Contains(text, "3\trow") || !strings.Contains(text, "4\trow") {
|
||||
t.Errorf("offset/limit window wrong: %q", text)
|
||||
}
|
||||
if strings.Contains(text, "2\trow") || strings.Contains(text, "5\trow") {
|
||||
t.Errorf("offset/limit leaked outside window: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolMissingFile(t *testing.T) {
|
||||
tool := &ReadTool{Root: t.TempDir()}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "nope.txt"})
|
||||
if !strings.Contains(resultText(res), "does not exist") {
|
||||
t.Errorf("expected does-not-exist error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A secret sits outside the root.
|
||||
parent := filepath.Dir(dir)
|
||||
secret := filepath.Join(parent, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
defer os.Remove(secret)
|
||||
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "../secret.txt"})
|
||||
text := resultText(res)
|
||||
if strings.Contains(text, "top secret") {
|
||||
t.Fatal("path traversal escaped the root!")
|
||||
}
|
||||
if !strings.Contains(text, "outside the workspace root") {
|
||||
t.Errorf("expected boundary error, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "subdir")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "subdir"})
|
||||
if !strings.Contains(resultText(res), "is a directory") {
|
||||
t.Errorf("expected directory error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolTruncation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var sb strings.Builder
|
||||
for i := 0; i < readToolMaxLines+50; i++ {
|
||||
sb.WriteString("x\n")
|
||||
}
|
||||
path := filepath.Join(dir, "big.txt")
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tool := &ReadTool{Root: dir}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": "big.txt"})
|
||||
if !strings.Contains(resultText(res), "output truncated") {
|
||||
t.Error("expected truncation notice for oversized file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolMissingPathArg(t *testing.T) {
|
||||
tool := &ReadTool{Root: t.TempDir()}
|
||||
res, _ := runRead(t, tool, map[string]any{})
|
||||
if !strings.Contains(resultText(res), "path is required") {
|
||||
t.Errorf("expected path-required error, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolExtraRootsAllowsTrustedOutsidePath(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
// A skill file lives OUTSIDE the workspace root (mirrors ~/.agents/skills).
|
||||
skills := t.TempDir()
|
||||
skillFile := filepath.Join(skills, "weather", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(skillFile), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(skillFile, []byte("skill body"), 0o644); err != nil {
|
||||
t.Fatalf("write skill: %v", err)
|
||||
}
|
||||
|
||||
// Without ExtraRoots the absolute skill path is rejected as out-of-workspace.
|
||||
bounded := &ReadTool{Root: work}
|
||||
res, _ := runRead(t, bounded, map[string]any{"path": skillFile})
|
||||
if !strings.Contains(resultText(res), "outside the workspace root") {
|
||||
t.Fatalf("expected boundary rejection without ExtraRoots, got %q", resultText(res))
|
||||
}
|
||||
|
||||
// With the skills dir as an extra root the same read succeeds.
|
||||
tool := &ReadTool{Root: work, ExtraRoots: []string{skills}}
|
||||
res, _ = runRead(t, tool, map[string]any{"path": skillFile})
|
||||
if !strings.Contains(resultText(res), "skill body") {
|
||||
t.Fatalf("expected skill contents with ExtraRoots, got %q", resultText(res))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolExtraRootsStillBlocksUntrustedPath(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
skills := t.TempDir()
|
||||
// A secret sits outside BOTH the workspace root and the extra root.
|
||||
other := t.TempDir()
|
||||
secret := filepath.Join(other, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil {
|
||||
t.Fatalf("write secret: %v", err)
|
||||
}
|
||||
|
||||
tool := &ReadTool{Root: work, ExtraRoots: []string{skills}}
|
||||
res, _ := runRead(t, tool, map[string]any{"path": secret})
|
||||
text := resultText(res)
|
||||
if strings.Contains(text, "top secret") {
|
||||
t.Fatal("read escaped both roots!")
|
||||
}
|
||||
if !strings.Contains(text, "outside the workspace root") {
|
||||
t.Errorf("expected boundary error for untrusted path, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadToolSchemaAndMode(t *testing.T) {
|
||||
tool := &ReadTool{}
|
||||
if tool.Name() != "read" {
|
||||
t.Errorf("name = %q", tool.Name())
|
||||
}
|
||||
if tool.ExecutionMode() != agentcore.ToolExecutionParallel {
|
||||
t.Errorf("read should be parallel")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
|
||||
t.Errorf("schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user